View Javadoc

1   /*
2    * Copyright 2011 Vincent Behar
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  package org.rundeck.api;
17  
18  import java.io.ByteArrayInputStream;
19  import java.io.IOException;
20  import java.io.InputStream;
21  import java.net.ProxySelector;
22  import java.security.KeyManagementException;
23  import java.security.KeyStoreException;
24  import java.security.NoSuchAlgorithmException;
25  import java.security.UnrecoverableKeyException;
26  import java.security.cert.CertificateException;
27  import java.security.cert.X509Certificate;
28  import java.util.ArrayList;
29  import java.util.List;
30  import java.util.Map.Entry;
31  import org.apache.commons.lang.StringUtils;
32  import org.apache.http.HttpResponse;
33  import org.apache.http.NameValuePair;
34  import org.apache.http.ParseException;
35  import org.apache.http.client.HttpClient;
36  import org.apache.http.client.entity.UrlEncodedFormEntity;
37  import org.apache.http.client.methods.HttpDelete;
38  import org.apache.http.client.methods.HttpGet;
39  import org.apache.http.client.methods.HttpPost;
40  import org.apache.http.client.methods.HttpRequestBase;
41  import org.apache.http.conn.scheme.Scheme;
42  import org.apache.http.conn.ssl.SSLSocketFactory;
43  import org.apache.http.conn.ssl.TrustStrategy;
44  import org.apache.http.entity.mime.HttpMultipartMode;
45  import org.apache.http.entity.mime.MultipartEntity;
46  import org.apache.http.entity.mime.content.InputStreamBody;
47  import org.apache.http.impl.client.DefaultHttpClient;
48  import org.apache.http.impl.conn.ProxySelectorRoutePlanner;
49  import org.apache.http.message.BasicNameValuePair;
50  import org.apache.http.protocol.HTTP;
51  import org.apache.http.util.EntityUtils;
52  import org.dom4j.Document;
53  import org.rundeck.api.RundeckApiException.RundeckApiLoginException;
54  import org.rundeck.api.parser.ParserHelper;
55  import org.rundeck.api.parser.XmlNodeParser;
56  import org.rundeck.api.util.AssertUtil;
57  
58  /**
59   * Class responsible for making the HTTP API calls
60   * 
61   * @author Vincent Behar
62   */
63  class ApiCall {
64  
65      private final RundeckClient client;
66  
67      /**
68       * Build a new instance, linked to the given RunDeck client
69       * 
70       * @param client holding the RunDeck url and the credentials
71       * @throws IllegalArgumentException if client is null
72       */
73      public ApiCall(RundeckClient client) throws IllegalArgumentException {
74          super();
75          this.client = client;
76          AssertUtil.notNull(client, "The RunDeck Client must not be null !");
77      }
78  
79      /**
80       * Try to "ping" the RunDeck instance to see if it is alive
81       * 
82       * @throws RundeckApiException if the ping fails
83       */
84      public void ping() throws RundeckApiException {
85          HttpClient httpClient = instantiateHttpClient();
86          try {
87              HttpResponse response = httpClient.execute(new HttpGet(client.getUrl()));
88              if (response.getStatusLine().getStatusCode() / 100 != 2) {
89                  throw new RundeckApiException("Invalid HTTP response '" + response.getStatusLine() + "' when pinging "
90                                                + client.getUrl());
91              }
92          } catch (IOException e) {
93              throw new RundeckApiException("Failed to ping RunDeck instance at " + client.getUrl(), e);
94          } finally {
95              httpClient.getConnectionManager().shutdown();
96          }
97      }
98  
99      /**
100      * Test the credentials (login/password) on the RunDeck instance
101      * 
102      * @throws RundeckApiLoginException if the login fails
103      */
104     public void testCredentials() throws RundeckApiLoginException {
105         HttpClient httpClient = instantiateHttpClient();
106         try {
107             login(httpClient);
108         } finally {
109             httpClient.getConnectionManager().shutdown();
110         }
111     }
112 
113     /**
114      * Execute an HTTP GET request to the RunDeck instance, on the given path. We will login first, and then execute the
115      * API call. At the end, the given parser will be used to convert the response to a more useful result object.
116      * 
117      * @param apiPath on which we will make the HTTP request - see {@link ApiPathBuilder}
118      * @param parser used to parse the response
119      * @return the result of the call, as formatted by the parser
120      * @throws RundeckApiException in case of error when calling the API
121      * @throws RundeckApiLoginException if the login fails
122      */
123     public <T> T get(ApiPathBuilder apiPath, XmlNodeParser<T> parser) throws RundeckApiException,
124             RundeckApiLoginException {
125         return execute(new HttpGet(client.getUrl() + RundeckClient.API_ENDPOINT + apiPath), parser);
126     }
127 
128     /**
129      * Execute an HTTP GET request to the RunDeck instance, on the given path. We will login first, and then execute the
130      * API call.
131      * 
132      * @param apiPath on which we will make the HTTP request - see {@link ApiPathBuilder}
133      * @return a new {@link InputStream} instance, not linked with network resources
134      * @throws RundeckApiException in case of error when calling the API
135      * @throws RundeckApiLoginException if the login fails
136      */
137     public InputStream get(ApiPathBuilder apiPath) throws RundeckApiException, RundeckApiLoginException {
138         ByteArrayInputStream response = execute(new HttpGet(client.getUrl() + RundeckClient.API_ENDPOINT + apiPath));
139 
140         // try to load the document, to throw an exception in case of error
141         ParserHelper.loadDocument(response);
142         response.reset();
143 
144         return response;
145     }
146 
147     /**
148      * Execute an HTTP POST request to the RunDeck instance, on the given path. We will login first, and then execute
149      * the API call. At the end, the given parser will be used to convert the response to a more useful result object.
150      * 
151      * @param apiPath on which we will make the HTTP request - see {@link ApiPathBuilder}
152      * @param parser used to parse the response
153      * @return the result of the call, as formatted by the parser
154      * @throws RundeckApiException in case of error when calling the API
155      * @throws RundeckApiLoginException if the login fails
156      */
157     public <T> T post(ApiPathBuilder apiPath, XmlNodeParser<T> parser) throws RundeckApiException,
158             RundeckApiLoginException {
159         HttpPost httpPost = new HttpPost(client.getUrl() + RundeckClient.API_ENDPOINT + apiPath);
160 
161         // POST a multi-part request, with all attachments
162         MultipartEntity entity = new MultipartEntity(HttpMultipartMode.BROWSER_COMPATIBLE);
163         for (Entry<String, InputStream> attachment : apiPath.getAttachments().entrySet()) {
164             entity.addPart(attachment.getKey(), new InputStreamBody(attachment.getValue(), attachment.getKey()));
165         }
166         httpPost.setEntity(entity);
167 
168         return execute(httpPost, parser);
169     }
170 
171     /**
172      * Execute an HTTP DELETE request to the RunDeck instance, on the given path. We will login first, and then execute
173      * the API call. At the end, the given parser will be used to convert the response to a more useful result object.
174      * 
175      * @param apiPath on which we will make the HTTP request - see {@link ApiPathBuilder}
176      * @param parser used to parse the response
177      * @return the result of the call, as formatted by the parser
178      * @throws RundeckApiException in case of error when calling the API
179      * @throws RundeckApiLoginException if the login fails
180      */
181     public <T> T delete(ApiPathBuilder apiPath, XmlNodeParser<T> parser) throws RundeckApiException,
182             RundeckApiLoginException {
183         return execute(new HttpDelete(client.getUrl() + RundeckClient.API_ENDPOINT + apiPath), parser);
184     }
185 
186     /**
187      * Execute an HTTP request to the RunDeck instance. We will login first, and then execute the API call. At the end,
188      * the given parser will be used to convert the response to a more useful result object.
189      * 
190      * @param request to execute. see {@link HttpGet}, {@link HttpDelete}, and so on...
191      * @param parser used to parse the response
192      * @return the result of the call, as formatted by the parser
193      * @throws RundeckApiException in case of error when calling the API
194      * @throws RundeckApiLoginException if the login fails
195      */
196     private <T> T execute(HttpRequestBase request, XmlNodeParser<T> parser) throws RundeckApiException,
197             RundeckApiLoginException {
198         // execute the request
199         InputStream response = execute(request);
200 
201         // read and parse the response
202         Document xmlDocument = ParserHelper.loadDocument(response);
203         return parser.parseXmlNode(xmlDocument);
204     }
205 
206     /**
207      * Execute an HTTP request to the RunDeck instance. We will login first, and then execute the API call.
208      * 
209      * @param request to execute. see {@link HttpGet}, {@link HttpDelete}, and so on...
210      * @return a new {@link InputStream} instance, not linked with network resources
211      * @throws RundeckApiException in case of error when calling the API
212      * @throws RundeckApiLoginException if the login fails
213      */
214     private ByteArrayInputStream execute(HttpRequestBase request) throws RundeckApiException, RundeckApiLoginException {
215         HttpClient httpClient = instantiateHttpClient();
216         try {
217             login(httpClient);
218 
219             // execute the HTTP request
220             HttpResponse response = null;
221             try {
222                 response = httpClient.execute(request);
223             } catch (IOException e) {
224                 throw new RundeckApiException("Failed to execute an HTTP " + request.getMethod() + " on url : "
225                                               + request.getURI(), e);
226             }
227 
228             // HTTP client refuses to handle redirects (code 3xx) for DELETE, so we have to do it manually...
229             // See http://rundeck.lighthouseapp.com/projects/59277/tickets/248
230             if (response.getStatusLine().getStatusCode() / 100 == 3
231                 && HttpDelete.METHOD_NAME.equals(request.getMethod())) {
232                 String newLocation = response.getFirstHeader("Location").getValue();
233                 try {
234                     EntityUtils.consume(response.getEntity());
235                 } catch (IOException e) {
236                     throw new RundeckApiException("Failed to consume entity (release connection)", e);
237                 }
238                 request = new HttpDelete(newLocation);
239                 try {
240                     response = httpClient.execute(request);
241                 } catch (IOException e) {
242                     throw new RundeckApiException("Failed to execute an HTTP " + request.getMethod() + " on url : "
243                                                   + request.getURI(), e);
244                 }
245             }
246 
247             // in case of error, we get a redirect to /api/error
248             // that we need to follow manually for POST and DELETE requests (as GET)
249             if (response.getStatusLine().getStatusCode() / 100 == 3) {
250                 String newLocation = response.getFirstHeader("Location").getValue();
251                 try {
252                     EntityUtils.consume(response.getEntity());
253                 } catch (IOException e) {
254                     throw new RundeckApiException("Failed to consume entity (release connection)", e);
255                 }
256                 request = new HttpGet(newLocation);
257                 try {
258                     response = httpClient.execute(request);
259                 } catch (IOException e) {
260                     throw new RundeckApiException("Failed to execute an HTTP GET on url : " + request.getURI(), e);
261                 }
262             }
263 
264             // check the response code (should be 2xx, even in case of error : error message is in the XML result)
265             if (response.getStatusLine().getStatusCode() / 100 != 2) {
266                 throw new RundeckApiException("Invalid HTTP response '" + response.getStatusLine() + "' for "
267                                               + request.getURI());
268             }
269             if (response.getEntity() == null) {
270                 throw new RundeckApiException("Empty RunDeck response ! HTTP status line is : "
271                                               + response.getStatusLine());
272             }
273 
274             // return a new inputStream, so that we can close all network resources
275             try {
276                 return new ByteArrayInputStream(EntityUtils.toByteArray(response.getEntity()));
277             } catch (IOException e) {
278                 throw new RundeckApiException("Failed to consume entity and convert the inputStream", e);
279             }
280         } finally {
281             httpClient.getConnectionManager().shutdown();
282         }
283     }
284 
285     /**
286      * Do the actual work of login, using the given {@link HttpClient} instance. You'll need to re-use this instance
287      * when making API calls (such as running a job).
288      * 
289      * @param httpClient pre-instantiated
290      * @throws RundeckApiLoginException if the login failed
291      */
292     private void login(HttpClient httpClient) throws RundeckApiLoginException {
293         String location = client.getUrl() + "/j_security_check";
294 
295         while (true) {
296             HttpPost postLogin = new HttpPost(location);
297             List<NameValuePair> params = new ArrayList<NameValuePair>();
298             params.add(new BasicNameValuePair("j_username", client.getLogin()));
299             params.add(new BasicNameValuePair("j_password", client.getPassword()));
300             params.add(new BasicNameValuePair("action", "login"));
301 
302             HttpResponse response = null;
303             try {
304                 postLogin.setEntity(new UrlEncodedFormEntity(params, HTTP.UTF_8));
305                 response = httpClient.execute(postLogin);
306             } catch (IOException e) {
307                 throw new RundeckApiLoginException("Failed to post login form on " + location, e);
308             }
309 
310             if (response.getStatusLine().getStatusCode() / 100 == 3) {
311                 // HTTP client refuses to handle redirects (code 3xx) for POST, so we have to do it manually...
312                 location = response.getFirstHeader("Location").getValue();
313                 try {
314                     EntityUtils.consume(response.getEntity());
315                 } catch (IOException e) {
316                     throw new RundeckApiLoginException("Failed to consume entity (release connection)", e);
317                 }
318                 continue;
319             }
320             if (response.getStatusLine().getStatusCode() / 100 != 2) {
321                 throw new RundeckApiLoginException("Invalid HTTP response '" + response.getStatusLine() + "' for "
322                                                    + location);
323             }
324             try {
325                 String content = EntityUtils.toString(response.getEntity(), HTTP.UTF_8);
326                 if (StringUtils.contains(content, "j_security_check")) {
327                     throw new RundeckApiLoginException("Login failed for user " + client.getLogin());
328                 }
329                 try {
330                     EntityUtils.consume(response.getEntity());
331                 } catch (IOException e) {
332                     throw new RundeckApiLoginException("Failed to consume entity (release connection)", e);
333                 }
334             } catch (IOException io) {
335                 throw new RundeckApiLoginException("Failed to read RunDeck result", io);
336             } catch (ParseException p) {
337                 throw new RundeckApiLoginException("Failed to parse RunDeck response", p);
338             }
339             break;
340         }
341     }
342 
343     /**
344      * Instantiate a new {@link HttpClient} instance, configured to accept all SSL certificates
345      * 
346      * @return an {@link HttpClient} instance - won't be null
347      */
348     private HttpClient instantiateHttpClient() {
349         DefaultHttpClient httpClient = new DefaultHttpClient();
350 
351         // configure SSL
352         SSLSocketFactory socketFactory = null;
353         try {
354             socketFactory = new SSLSocketFactory(new TrustStrategy() {
355 
356                 @Override
357                 public boolean isTrusted(X509Certificate[] chain, String authType) throws CertificateException {
358                     return true;
359                 }
360             }, SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
361         } catch (KeyManagementException e) {
362             throw new RuntimeException(e);
363         } catch (UnrecoverableKeyException e) {
364             throw new RuntimeException(e);
365         } catch (NoSuchAlgorithmException e) {
366             throw new RuntimeException(e);
367         } catch (KeyStoreException e) {
368             throw new RuntimeException(e);
369         }
370         httpClient.getConnectionManager().getSchemeRegistry().register(new Scheme("https", 443, socketFactory));
371 
372         // configure proxy (use system env : http.proxyHost / http.proxyPort)
373         System.setProperty("java.net.useSystemProxies", "true");
374         httpClient.setRoutePlanner(new ProxySelectorRoutePlanner(httpClient.getConnectionManager().getSchemeRegistry(),
375                                                                  ProxySelector.getDefault()));
376 
377         return httpClient;
378     }
379 }