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.HttpException;
33  import org.apache.http.HttpRequest;
34  import org.apache.http.HttpRequestInterceptor;
35  import org.apache.http.HttpResponse;
36  import org.apache.http.NameValuePair;
37  import org.apache.http.ParseException;
38  import org.apache.http.client.HttpClient;
39  import org.apache.http.client.entity.UrlEncodedFormEntity;
40  import org.apache.http.client.methods.HttpDelete;
41  import org.apache.http.client.methods.HttpGet;
42  import org.apache.http.client.methods.HttpPost;
43  import org.apache.http.client.methods.HttpRequestBase;
44  import org.apache.http.conn.scheme.Scheme;
45  import org.apache.http.conn.ssl.SSLSocketFactory;
46  import org.apache.http.conn.ssl.TrustStrategy;
47  import org.apache.http.entity.mime.HttpMultipartMode;
48  import org.apache.http.entity.mime.MultipartEntity;
49  import org.apache.http.entity.mime.content.InputStreamBody;
50  import org.apache.http.impl.client.DefaultHttpClient;
51  import org.apache.http.impl.conn.ProxySelectorRoutePlanner;
52  import org.apache.http.message.BasicNameValuePair;
53  import org.apache.http.params.HttpProtocolParams;
54  import org.apache.http.protocol.HTTP;
55  import org.apache.http.protocol.HttpContext;
56  import org.apache.http.util.EntityUtils;
57  import org.dom4j.Document;
58  import org.rundeck.api.RundeckApiException.RundeckApiLoginException;
59  import org.rundeck.api.RundeckApiException.RundeckApiTokenException;
60  import org.rundeck.api.parser.ParserHelper;
61  import org.rundeck.api.parser.XmlNodeParser;
62  import org.rundeck.api.util.AssertUtil;
63  
64  /**
65   * Class responsible for making the HTTP API calls
66   * 
67   * @author Vincent Behar
68   */
69  class ApiCall {
70  
71      /** RunDeck HTTP header for the auth-token (in case of token-based authentication) */
72      private static final transient String AUTH_TOKEN_HEADER = "X-RunDeck-Auth-Token";
73  
74      /** {@link RundeckClient} instance holding the RunDeck url and the credentials */
75      private final RundeckClient client;
76  
77      /**
78       * Build a new instance, linked to the given RunDeck client
79       * 
80       * @param client holding the RunDeck url and the credentials
81       * @throws IllegalArgumentException if client is null
82       */
83      public ApiCall(RundeckClient client) throws IllegalArgumentException {
84          super();
85          this.client = client;
86          AssertUtil.notNull(client, "The RunDeck Client must not be null !");
87      }
88  
89      /**
90       * Try to "ping" the RunDeck instance to see if it is alive
91       * 
92       * @throws RundeckApiException if the ping fails
93       */
94      public void ping() throws RundeckApiException {
95          HttpClient httpClient = instantiateHttpClient();
96          try {
97              HttpResponse response = httpClient.execute(new HttpGet(client.getUrl()));
98              if (response.getStatusLine().getStatusCode() / 100 != 2) {
99                  throw new RundeckApiException("Invalid HTTP response '" + response.getStatusLine() + "' when pinging "
100                                               + client.getUrl());
101             }
102         } catch (IOException e) {
103             throw new RundeckApiException("Failed to ping RunDeck instance at " + client.getUrl(), e);
104         } finally {
105             httpClient.getConnectionManager().shutdown();
106         }
107     }
108 
109     /**
110      * Test the authentication on the RunDeck instance. Will delegate to either {@link #testLoginAuth()} (in case of
111      * login-based auth) or {@link #testTokenAuth()} (in case of token-based auth).
112      * 
113      * @throws RundeckApiLoginException if the login fails (in case of login-based authentication)
114      * @throws RundeckApiTokenException if the token is invalid (in case of token-based authentication)
115      * @see #testLoginAuth()
116      * @see #testTokenAuth()
117      */
118     public void testAuth() throws RundeckApiLoginException, RundeckApiTokenException {
119         if (client.getToken() != null) {
120             testTokenAuth();
121         } else {
122             testLoginAuth();
123         }
124     }
125 
126     /**
127      * Test the login-based authentication on the RunDeck instance
128      * 
129      * @throws RundeckApiLoginException if the login fails
130      * @see #testAuth()
131      */
132     public void testLoginAuth() throws RundeckApiLoginException {
133         HttpClient httpClient = instantiateHttpClient();
134         try {
135             login(httpClient);
136         } finally {
137             httpClient.getConnectionManager().shutdown();
138         }
139     }
140 
141     /**
142      * Test the token-based authentication on the RunDeck instance
143      * 
144      * @throws RundeckApiTokenException if the token is invalid
145      * @see #testAuth()
146      */
147     public void testTokenAuth() throws RundeckApiTokenException {
148         try {
149             execute(new HttpGet(client.getUrl() + RundeckClient.API_ENDPOINT + "/system/info"));
150         } catch (RundeckApiTokenException e) {
151             throw e;
152         } catch (RundeckApiException e) {
153             throw new RundeckApiTokenException("Failed to verify token", e);
154         }
155     }
156 
157     /**
158      * Execute an HTTP GET request to the RunDeck instance, on the given path. We will login first, and then execute the
159      * API call. At the end, the given parser will be used to convert the response to a more useful result object.
160      * 
161      * @param apiPath on which we will make the HTTP request - see {@link ApiPathBuilder}
162      * @param parser used to parse the response
163      * @return the result of the call, as formatted by the parser
164      * @throws RundeckApiException in case of error when calling the API
165      * @throws RundeckApiLoginException if the login fails (in case of login-based authentication)
166      * @throws RundeckApiTokenException if the token is invalid (in case of token-based authentication)
167      */
168     public <T> T get(ApiPathBuilder apiPath, XmlNodeParser<T> parser) throws RundeckApiException,
169             RundeckApiLoginException, RundeckApiTokenException {
170         return execute(new HttpGet(client.getUrl() + RundeckClient.API_ENDPOINT + apiPath), parser);
171     }
172 
173     /**
174      * Execute an HTTP GET request to the RunDeck instance, on the given path. We will login first, and then execute the
175      * API call.
176      * 
177      * @param apiPath on which we will make the HTTP request - see {@link ApiPathBuilder}
178      * @return a new {@link InputStream} instance, not linked with network resources
179      * @throws RundeckApiException in case of error when calling the API
180      * @throws RundeckApiLoginException if the login fails (in case of login-based authentication)
181      * @throws RundeckApiTokenException if the token is invalid (in case of token-based authentication)
182      */
183     public InputStream get(ApiPathBuilder apiPath) throws RundeckApiException, RundeckApiLoginException,
184             RundeckApiTokenException {
185         ByteArrayInputStream response = execute(new HttpGet(client.getUrl() + RundeckClient.API_ENDPOINT + apiPath));
186 
187         // try to load the document, to throw an exception in case of error
188         ParserHelper.loadDocument(response);
189         response.reset();
190 
191         return response;
192     }
193 
194     /**
195      * Execute an HTTP POST request to the RunDeck instance, on the given path. We will login first, and then execute
196      * the API call. At the end, the given parser will be used to convert the response to a more useful result object.
197      * 
198      * @param apiPath on which we will make the HTTP request - see {@link ApiPathBuilder}
199      * @param parser used to parse the response
200      * @return the result of the call, as formatted by the parser
201      * @throws RundeckApiException in case of error when calling the API
202      * @throws RundeckApiLoginException if the login fails (in case of login-based authentication)
203      * @throws RundeckApiTokenException if the token is invalid (in case of token-based authentication)
204      */
205     public <T> T post(ApiPathBuilder apiPath, XmlNodeParser<T> parser) throws RundeckApiException,
206             RundeckApiLoginException, RundeckApiTokenException {
207         HttpPost httpPost = new HttpPost(client.getUrl() + RundeckClient.API_ENDPOINT + apiPath);
208 
209         // POST a multi-part request, with all attachments
210         MultipartEntity entity = new MultipartEntity(HttpMultipartMode.BROWSER_COMPATIBLE);
211         for (Entry<String, InputStream> attachment : apiPath.getAttachments().entrySet()) {
212             entity.addPart(attachment.getKey(), new InputStreamBody(attachment.getValue(), attachment.getKey()));
213         }
214         httpPost.setEntity(entity);
215 
216         return execute(httpPost, parser);
217     }
218 
219     /**
220      * Execute an HTTP DELETE request to the RunDeck instance, on the given path. We will login first, and then execute
221      * the API call. At the end, the given parser will be used to convert the response to a more useful result object.
222      * 
223      * @param apiPath on which we will make the HTTP request - see {@link ApiPathBuilder}
224      * @param parser used to parse the response
225      * @return the result of the call, as formatted by the parser
226      * @throws RundeckApiException in case of error when calling the API
227      * @throws RundeckApiLoginException if the login fails (in case of login-based authentication)
228      * @throws RundeckApiTokenException if the token is invalid (in case of token-based authentication)
229      */
230     public <T> T delete(ApiPathBuilder apiPath, XmlNodeParser<T> parser) throws RundeckApiException,
231             RundeckApiLoginException, RundeckApiTokenException {
232         return execute(new HttpDelete(client.getUrl() + RundeckClient.API_ENDPOINT + apiPath), parser);
233     }
234 
235     /**
236      * Execute an HTTP request to the RunDeck instance. We will login first, and then execute the API call. At the end,
237      * the given parser will be used to convert the response to a more useful result object.
238      * 
239      * @param request to execute. see {@link HttpGet}, {@link HttpDelete}, and so on...
240      * @param parser used to parse the response
241      * @return the result of the call, as formatted by the parser
242      * @throws RundeckApiException in case of error when calling the API
243      * @throws RundeckApiLoginException if the login fails (in case of login-based authentication)
244      * @throws RundeckApiTokenException if the token is invalid (in case of token-based authentication)
245      */
246     private <T> T execute(HttpRequestBase request, XmlNodeParser<T> parser) throws RundeckApiException,
247             RundeckApiLoginException, RundeckApiTokenException {
248         // execute the request
249         InputStream response = execute(request);
250 
251         // read and parse the response
252         Document xmlDocument = ParserHelper.loadDocument(response);
253         return parser.parseXmlNode(xmlDocument);
254     }
255 
256     /**
257      * Execute an HTTP request to the RunDeck instance. We will login first, and then execute the API call.
258      * 
259      * @param request to execute. see {@link HttpGet}, {@link HttpDelete}, and so on...
260      * @return a new {@link InputStream} instance, not linked with network resources
261      * @throws RundeckApiException in case of error when calling the API
262      * @throws RundeckApiLoginException if the login fails (in case of login-based authentication)
263      * @throws RundeckApiTokenException if the token is invalid (in case of token-based authentication)
264      */
265     private ByteArrayInputStream execute(HttpRequestBase request) throws RundeckApiException, RundeckApiLoginException,
266             RundeckApiTokenException {
267         HttpClient httpClient = instantiateHttpClient();
268         try {
269             // we only need to manually login in case of login-based authentication
270             // note that in case of token-based auth, the auth (via an HTTP header) is managed by an interceptor.
271             if (client.getToken() == null) {
272                 login(httpClient);
273             }
274 
275             // execute the HTTP request
276             HttpResponse response = null;
277             try {
278                 response = httpClient.execute(request);
279             } catch (IOException e) {
280                 throw new RundeckApiException("Failed to execute an HTTP " + request.getMethod() + " on url : "
281                                               + request.getURI(), e);
282             }
283 
284             // in case of error, we get a redirect to /api/error
285             // that we need to follow manually for POST and DELETE requests (as GET)
286             if (response.getStatusLine().getStatusCode() / 100 == 3) {
287                 String newLocation = response.getFirstHeader("Location").getValue();
288                 try {
289                     EntityUtils.consume(response.getEntity());
290                 } catch (IOException e) {
291                     throw new RundeckApiException("Failed to consume entity (release connection)", e);
292                 }
293                 request = new HttpGet(newLocation);
294                 try {
295                     response = httpClient.execute(request);
296                 } catch (IOException e) {
297                     throw new RundeckApiException("Failed to execute an HTTP GET on url : " + request.getURI(), e);
298                 }
299             }
300 
301             // check the response code (should be 2xx, even in case of error : error message is in the XML result)
302             if (response.getStatusLine().getStatusCode() / 100 != 2) {
303                 if (response.getStatusLine().getStatusCode() == 403 && client.getToken() != null) {
304                     throw new RundeckApiTokenException("Invalid Token ! Got HTTP response '" + response.getStatusLine()
305                                                        + "' for " + request.getURI());
306                 } else {
307                     throw new RundeckApiException("Invalid HTTP response '" + response.getStatusLine() + "' for "
308                                                   + request.getURI());
309                 }
310             }
311             if (response.getEntity() == null) {
312                 throw new RundeckApiException("Empty RunDeck response ! HTTP status line is : "
313                                               + response.getStatusLine());
314             }
315 
316             // return a new inputStream, so that we can close all network resources
317             try {
318                 return new ByteArrayInputStream(EntityUtils.toByteArray(response.getEntity()));
319             } catch (IOException e) {
320                 throw new RundeckApiException("Failed to consume entity and convert the inputStream", e);
321             }
322         } finally {
323             httpClient.getConnectionManager().shutdown();
324         }
325     }
326 
327     /**
328      * Do the actual work of login, using the given {@link HttpClient} instance. You'll need to re-use this instance
329      * when making API calls (such as running a job). Only use this in case of login-based authentication.
330      * 
331      * @param httpClient pre-instantiated
332      * @throws RundeckApiLoginException if the login failed
333      */
334     private void login(HttpClient httpClient) throws RundeckApiLoginException {
335         String location = client.getUrl() + "/j_security_check";
336 
337         while (true) {
338             HttpPost postLogin = new HttpPost(location);
339             List<NameValuePair> params = new ArrayList<NameValuePair>();
340             params.add(new BasicNameValuePair("j_username", client.getLogin()));
341             params.add(new BasicNameValuePair("j_password", client.getPassword()));
342             params.add(new BasicNameValuePair("action", "login"));
343 
344             HttpResponse response = null;
345             try {
346                 postLogin.setEntity(new UrlEncodedFormEntity(params, HTTP.UTF_8));
347                 response = httpClient.execute(postLogin);
348             } catch (IOException e) {
349                 throw new RundeckApiLoginException("Failed to post login form on " + location, e);
350             }
351 
352             if (response.getStatusLine().getStatusCode() / 100 == 3) {
353                 // HTTP client refuses to handle redirects (code 3xx) for POST, so we have to do it manually...
354                 location = response.getFirstHeader("Location").getValue();
355                 try {
356                     EntityUtils.consume(response.getEntity());
357                 } catch (IOException e) {
358                     throw new RundeckApiLoginException("Failed to consume entity (release connection)", e);
359                 }
360                 continue;
361             }
362             if (response.getStatusLine().getStatusCode() / 100 != 2) {
363                 throw new RundeckApiLoginException("Invalid HTTP response '" + response.getStatusLine() + "' for "
364                                                    + location);
365             }
366             try {
367                 String content = EntityUtils.toString(response.getEntity(), HTTP.UTF_8);
368                 if (StringUtils.contains(content, "j_security_check")) {
369                     throw new RundeckApiLoginException("Login failed for user " + client.getLogin());
370                 }
371                 try {
372                     EntityUtils.consume(response.getEntity());
373                 } catch (IOException e) {
374                     throw new RundeckApiLoginException("Failed to consume entity (release connection)", e);
375                 }
376             } catch (IOException io) {
377                 throw new RundeckApiLoginException("Failed to read RunDeck result", io);
378             } catch (ParseException p) {
379                 throw new RundeckApiLoginException("Failed to parse RunDeck response", p);
380             }
381             break;
382         }
383     }
384 
385     /**
386      * Instantiate a new {@link HttpClient} instance, configured to accept all SSL certificates
387      * 
388      * @return an {@link HttpClient} instance - won't be null
389      */
390     private HttpClient instantiateHttpClient() {
391         DefaultHttpClient httpClient = new DefaultHttpClient();
392 
393         // configure user-agent
394         HttpProtocolParams.setUserAgent(httpClient.getParams(), "RunDeck API Java Client " + RundeckClient.API_VERSION);
395 
396         // configure SSL
397         SSLSocketFactory socketFactory = null;
398         try {
399             socketFactory = new SSLSocketFactory(new TrustStrategy() {
400 
401                 @Override
402                 public boolean isTrusted(X509Certificate[] chain, String authType) throws CertificateException {
403                     return true;
404                 }
405             }, SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
406         } catch (KeyManagementException e) {
407             throw new RuntimeException(e);
408         } catch (UnrecoverableKeyException e) {
409             throw new RuntimeException(e);
410         } catch (NoSuchAlgorithmException e) {
411             throw new RuntimeException(e);
412         } catch (KeyStoreException e) {
413             throw new RuntimeException(e);
414         }
415         httpClient.getConnectionManager().getSchemeRegistry().register(new Scheme("https", 443, socketFactory));
416 
417         // configure proxy (use system env : http.proxyHost / http.proxyPort)
418         System.setProperty("java.net.useSystemProxies", "true");
419         httpClient.setRoutePlanner(new ProxySelectorRoutePlanner(httpClient.getConnectionManager().getSchemeRegistry(),
420                                                                  ProxySelector.getDefault()));
421 
422         // in case of token-based authentication, add the correct HTTP header to all requests via an interceptor
423         httpClient.addRequestInterceptor(new HttpRequestInterceptor() {
424 
425             @Override
426             public void process(HttpRequest request, HttpContext context) throws HttpException, IOException {
427                 if (client.getToken() != null) {
428                     request.addHeader(AUTH_TOKEN_HEADER, client.getToken());
429                 }
430             }
431         });
432 
433         return httpClient;
434     }
435 }