View Javadoc

1   /**
2   * Licensed to the Apache Software Foundation (ASF) under one
3   * or more contributor license agreements.  See the NOTICE file
4   * distributed with this work for additional information
5   * regarding copyright ownership.  The ASF licenses this file
6   * to you under the Apache License, Version 2.0 (the
7   * "License"); you may not use this file except in compliance
8   * with the License.  You may obtain a copy of the License at
9   *
10  *     http://www.apache.org/licenses/LICENSE-2.0
11  *
12  * Unless required by applicable law or agreed to in writing, software
13  * distributed under the License is distributed on an "AS IS" BASIS,
14  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15  * See the License for the specific language governing permissions and
16  * limitations under the License.
17  */
18  package org.apache.hadoop.hbase.http.log;
19  
20  import static org.junit.Assert.assertEquals;
21  import static org.junit.Assert.assertFalse;
22  import static org.junit.Assert.assertNotEquals;
23  import static org.junit.Assert.assertTrue;
24  import static org.junit.Assert.fail;
25  
26  import com.google.common.base.Joiner;
27  import java.io.File;
28  import java.net.BindException;
29  import java.net.SocketException;
30  import java.net.URI;
31  import java.security.PrivilegedExceptionAction;
32  import java.util.Properties;
33  import javax.net.ssl.SSLException;
34  
35  import org.apache.commons.io.FileUtils;
36  import org.apache.hadoop.HadoopIllegalArgumentException;
37  import org.apache.hadoop.conf.Configuration;
38  import org.apache.hadoop.fs.CommonConfigurationKeys;
39  import org.apache.hadoop.fs.CommonConfigurationKeysPublic;
40  import org.apache.hadoop.fs.FileUtil;
41  import org.apache.hadoop.hbase.HBaseCommonTestingUtility;
42  import org.apache.hadoop.hbase.http.HttpConfig;
43  import org.apache.hadoop.hbase.http.HttpServer;
44  import org.apache.hadoop.hbase.http.log.LogLevel.CLI;
45  import org.apache.hadoop.hbase.http.ssl.KeyStoreTestUtil;
46  import org.apache.hadoop.hbase.testclassification.MiscTests;
47  import org.apache.hadoop.hbase.testclassification.SmallTests;
48  import org.apache.hadoop.hdfs.DFSConfigKeys;
49  import org.apache.hadoop.minikdc.MiniKdc;
50  import org.apache.hadoop.net.NetUtils;
51  import org.apache.hadoop.security.UserGroupInformation;
52  import org.apache.hadoop.security.authorize.AccessControlList;
53  import org.apache.hadoop.security.ssl.SSLFactory;
54  import org.apache.hadoop.test.GenericTestUtils;
55  import org.apache.hadoop.util.StringUtils;
56  import org.apache.log4j.Level;
57  import org.apache.log4j.LogManager;
58  import org.apache.log4j.Logger;
59  
60  import org.junit.AfterClass;
61  import org.junit.BeforeClass;
62  import org.junit.Test;
63  import org.junit.experimental.categories.Category;
64  
65  /**
66   * Test LogLevel.
67   */
68  @Category({MiscTests.class, SmallTests.class})
69  public class TestLogLevel {
70    private static File BASEDIR;
71    private static String keystoresDir;
72    private static String sslConfDir;
73    private static Configuration serverConf;
74    private static Configuration clientConf;
75    private static Configuration sslConf;
76    private static final String logName = TestLogLevel.class.getName();
77    private static final Logger log = LogManager.getLogger(logName);
78    private final static String PRINCIPAL = "loglevel.principal";
79    private final static String KEYTAB  = "loglevel.keytab";
80  
81    private static MiniKdc kdc;
82    private static HBaseCommonTestingUtility htu = new HBaseCommonTestingUtility();
83  
84    private static final String LOCALHOST = "localhost";
85    private static final String clientPrincipal = "client/" + LOCALHOST;
86    private static String HTTP_PRINCIPAL = "HTTP/" + LOCALHOST;
87  
88    private static final File KEYTAB_FILE = new File(
89        htu.getDataTestDir("keytab").toUri().getPath());
90  
91    @BeforeClass
92    public static void setUp() throws Exception {
93      BASEDIR = new File(htu.getDataTestDir().toUri().getPath());
94  
95      FileUtil.fullyDelete(BASEDIR);
96      if (!BASEDIR.mkdirs()) {
97        throw new Exception("unable to create the base directory for testing");
98      }
99      serverConf = new Configuration();
100     clientConf = new Configuration();
101 
102     setupSSL(BASEDIR);
103 
104     kdc = setupMiniKdc();
105     // Create two principles: a client and a HTTP principal
106     kdc.createPrincipal(KEYTAB_FILE, clientPrincipal, HTTP_PRINCIPAL);
107   }
108 
109   /**
110    * Sets up {@link MiniKdc} for testing security.
111    * Copied from HBaseTestingUtility#setupMiniKdc().
112    */
113   static private MiniKdc setupMiniKdc() throws Exception {
114     Properties conf = MiniKdc.createConf();
115     conf.put(MiniKdc.DEBUG, true);
116     MiniKdc kdc = null;
117     File dir = null;
118     // There is time lag between selecting a port and trying to bind with it. It's possible that
119     // another service captures the port in between which'll result in BindException.
120     boolean bindException;
121     int numTries = 0;
122     do {
123       try {
124         bindException = false;
125         dir = new File(htu.getDataTestDir("kdc").toUri().getPath());
126         kdc = new MiniKdc(conf, dir);
127         kdc.start();
128       } catch (BindException e) {
129         FileUtils.deleteDirectory(dir);  // clean directory
130         numTries++;
131         if (numTries == 3) {
132           log.error("Failed setting up MiniKDC. Tried " + numTries + " times.");
133           throw e;
134         }
135         log.error("BindException encountered when setting up MiniKdc. Trying again.");
136         bindException = true;
137       }
138     } while (bindException);
139     return kdc;
140   }
141 
142   static private void setupSSL(File base) throws Exception {
143     Configuration conf = new Configuration();
144     conf.set(DFSConfigKeys.DFS_HTTP_POLICY_KEY, HttpConfig.Policy.HTTPS_ONLY.name());
145     conf.set(DFSConfigKeys.DFS_NAMENODE_HTTPS_ADDRESS_KEY, "localhost:0");
146     conf.set(DFSConfigKeys.DFS_DATANODE_HTTPS_ADDRESS_KEY, "localhost:0");
147 
148     keystoresDir = base.getAbsolutePath();
149     sslConfDir = KeyStoreTestUtil.getClasspathDir(TestLogLevel.class);
150     KeyStoreTestUtil.setupSSLConfig(keystoresDir, sslConfDir, conf, false);
151 
152     sslConf = getSslConfig();
153   }
154 
155   /**
156    * Get the SSL configuration.
157    * This method is copied from KeyStoreTestUtil#getSslConfig() in Hadoop.
158    * @return {@link Configuration} instance with ssl configs loaded.
159    */
160   private static Configuration getSslConfig() {
161     Configuration sslConf = new Configuration(false);
162     String sslServerConfFile = "ssl-server.xml";
163     String sslClientConfFile = "ssl-client.xml";
164     sslConf.addResource(sslServerConfFile);
165     sslConf.addResource(sslClientConfFile);
166     sslConf.set(SSLFactory.SSL_SERVER_CONF_KEY, sslServerConfFile);
167     sslConf.set(SSLFactory.SSL_CLIENT_CONF_KEY, sslClientConfFile);
168     return sslConf;
169   }
170 
171   @AfterClass
172   public static void tearDown() {
173     if (kdc != null) {
174       kdc.stop();
175     }
176 
177     FileUtil.fullyDelete(BASEDIR);
178   }
179 
180   /**
181    * Test client command line options. Does not validate server behavior.
182    * @throws Exception if commands return unexpected results.
183    */
184   @Test(timeout=120000)
185   public void testCommandOptions() throws Exception {
186     final String className = this.getClass().getName();
187 
188     assertFalse(validateCommand(new String[] {"-foo" }));
189     // fail due to insufficient number of arguments
190     assertFalse(validateCommand(new String[] {}));
191     assertFalse(validateCommand(new String[] {"-getlevel" }));
192     assertFalse(validateCommand(new String[] {"-setlevel" }));
193     assertFalse(validateCommand(new String[] {"-getlevel", "foo.bar:8080" }));
194 
195     // valid command arguments
196     assertTrue(validateCommand(
197         new String[] {"-getlevel", "foo.bar:8080", className }));
198     assertTrue(validateCommand(
199         new String[] {"-setlevel", "foo.bar:8080", className, "DEBUG" }));
200     assertTrue(validateCommand(
201         new String[] {"-getlevel", "foo.bar:8080", className }));
202     assertTrue(validateCommand(
203         new String[] {"-setlevel", "foo.bar:8080", className, "DEBUG" }));
204 
205     // fail due to the extra argument
206     assertFalse(validateCommand(
207         new String[] {"-getlevel", "foo.bar:8080", className, "blah" }));
208     assertFalse(validateCommand(
209         new String[] {"-setlevel", "foo.bar:8080", className, "DEBUG", "blah" }));
210     assertFalse(validateCommand(
211         new String[] {
212           "-getlevel", "foo.bar:8080", className, "-setlevel", "foo.bar:8080", className }));
213   }
214 
215   /**
216    * Check to see if a command can be accepted.
217    *
218    * @param args a String array of arguments
219    * @return true if the command can be accepted, false if not.
220    */
221   private boolean validateCommand(String[] args) {
222     CLI cli = new CLI(clientConf);
223     try {
224       cli.parseArguments(args);
225     } catch (HadoopIllegalArgumentException e) {
226       return false;
227     } catch (Exception e) {
228       // this is used to verify the command arguments only.
229       // no HadoopIllegalArgumentException = the arguments are good.
230       return true;
231     }
232     return true;
233   }
234 
235   /**
236    * Creates and starts a Jetty server binding at an ephemeral port to run
237    * LogLevel servlet.
238    * @param protocol "http" or "https"
239    * @param isSpnego true if SPNEGO is enabled
240    * @return a created HttpServer object
241    * @throws Exception if unable to create or start a Jetty server
242    */
243   private HttpServer createServer(String protocol, boolean isSpnego)
244       throws Exception {
245     HttpServer.Builder builder = new HttpServer.Builder()
246         .setName("..")
247         .addEndpoint(new URI(protocol + "://localhost:0"))
248         .setFindPort(true)
249         .setConf(serverConf);
250     if (isSpnego) {
251       // Set up server Kerberos credentials.
252       // Since the server may fall back to simple authentication,
253       // use ACL to make sure the connection is Kerberos/SPNEGO authenticated.
254       builder.setSecurityEnabled(true)
255           .setUsernameConfKey(PRINCIPAL)
256           .setKeytabConfKey(KEYTAB)
257           .setACL(new AccessControlList("client"));
258     }
259 
260     // if using HTTPS, configure keystore/truststore properties.
261     if (protocol.equals(LogLevel.PROTOCOL_HTTPS)) {
262       builder = builder.keyPassword(sslConf.get("ssl.server.keystore.keypassword"))
263                        .keyStore(sslConf.get("ssl.server.keystore.location"),
264                          sslConf.get("ssl.server.keystore.password"),
265                          sslConf.get("ssl.server.keystore.type", "jks"))
266                        .trustStore(sslConf.get("ssl.server.truststore.location"),
267                          sslConf.get("ssl.server.truststore.password"),
268                          sslConf.get("ssl.server.truststore.type", "jks"));
269     }
270 
271     HttpServer server = builder.build();
272     server.start();
273     return server;
274   }
275 
276   private void testDynamicLogLevel(final String bindProtocol, final String connectProtocol,
277       final boolean isSpnego) throws Exception {
278     testDynamicLogLevel(bindProtocol, connectProtocol, isSpnego, Level.DEBUG.toString());
279   }
280 
281   /**
282    * Run both client and server using the given protocol.
283    *
284    * @param bindProtocol specify either http or https for server
285    * @param connectProtocol specify either http or https for client
286    * @param isSpnego true if SPNEGO is enabled
287    * @throws Exception if client can't accesss server.
288    */
289   private void testDynamicLogLevel(final String bindProtocol, final String connectProtocol,
290       final boolean isSpnego, final String newLevel) throws Exception {
291     if (!LogLevel.isValidProtocol(bindProtocol)) {
292       throw new Exception("Invalid server protocol " + bindProtocol);
293     }
294     if (!LogLevel.isValidProtocol(connectProtocol)) {
295       throw new Exception("Invalid client protocol " + connectProtocol);
296     }
297     Level oldLevel = log.getEffectiveLevel();
298     assertNotEquals("Get default Log Level which shouldn't be ERROR.",
299         Level.ERROR, oldLevel);
300 
301     // configs needed for SPNEGO at server side
302     if (isSpnego) {
303       serverConf.set(PRINCIPAL, HTTP_PRINCIPAL);
304       serverConf.set(KEYTAB, KEYTAB_FILE.getAbsolutePath());
305       serverConf.set(CommonConfigurationKeysPublic.HADOOP_SECURITY_AUTHENTICATION, "kerberos");
306       serverConf.setBoolean(CommonConfigurationKeys.HADOOP_SECURITY_AUTHORIZATION, true);
307       UserGroupInformation.setConfiguration(serverConf);
308     } else {
309       serverConf.set(CommonConfigurationKeysPublic.HADOOP_SECURITY_AUTHENTICATION, "simple");
310       serverConf.setBoolean(CommonConfigurationKeys.HADOOP_SECURITY_AUTHORIZATION, false);
311       UserGroupInformation.setConfiguration(serverConf);
312     }
313 
314     final HttpServer server = createServer(bindProtocol, isSpnego);
315     // get server port
316     final String authority = NetUtils.getHostPortString(server.getConnectorAddress(0));
317 
318     String keytabFilePath = KEYTAB_FILE.getAbsolutePath();
319 
320     UserGroupInformation clientUGI = UserGroupInformation.
321         loginUserFromKeytabAndReturnUGI(clientPrincipal, keytabFilePath);
322     try {
323       clientUGI.doAs(new PrivilegedExceptionAction<Void>() {
324         @Override public Void run() throws Exception {
325           // client command line
326           getLevel(connectProtocol, authority);
327           setLevel(connectProtocol, authority, newLevel);
328           return null;
329         }
330       });
331     } finally {
332       server.stop();
333     }
334 
335     // restore log level
336     GenericTestUtils.setLogLevel(log, oldLevel);
337   }
338 
339   /**
340    * Run LogLevel command line to start a client to get log level of this test
341    * class.
342    *
343    * @param protocol specify either http or https
344    * @param authority daemon's web UI address
345    * @throws Exception if unable to connect
346    */
347   private void getLevel(String protocol, String authority) throws Exception {
348     String[] getLevelArgs = {"-getlevel", authority, logName, "-protocol", protocol};
349     CLI cli = new CLI(clientConf);
350     cli.run(getLevelArgs);
351   }
352 
353   /**
354    * Run LogLevel command line to start a client to set log level of this test
355    * class to debug.
356    *
357    * @param protocol specify either http or https
358    * @param authority daemon's web UI address
359    * @throws Exception if unable to run or log level does not change as expected
360    */
361   private void setLevel(String protocol, String authority, String newLevel)
362       throws Exception {
363     String[] setLevelArgs = {"-setlevel", authority, logName, newLevel, "-protocol", protocol};
364     CLI cli = new CLI(clientConf);
365     cli.run(setLevelArgs);
366 
367     assertEquals("new level not equal to expected: ", newLevel.toUpperCase(),
368         log.getEffectiveLevel().toString());
369   }
370 
371   /**
372    * Test setting log level to "Info".
373    *
374    * @throws Exception if client can't set log level to INFO.
375    */
376   @Test(timeout=60000)
377   public void testInfoLogLevel() throws Exception {
378     testDynamicLogLevel(LogLevel.PROTOCOL_HTTP, LogLevel.PROTOCOL_HTTP, true, "INFO");
379   }
380 
381   /**
382    * Test setting log level to "Error".
383    *
384    * @throws Exception if client can't set log level to ERROR.
385    */
386   @Test(timeout=60000)
387   public void testErrorLogLevel() throws Exception {
388     testDynamicLogLevel(LogLevel.PROTOCOL_HTTP, LogLevel.PROTOCOL_HTTP, true, "ERROR");
389   }
390 
391   /**
392    * Server runs HTTP, no SPNEGO.
393    *
394    * @throws Exception if http client can't access http server.
395    *   or http client can access https server.
396    */
397   @Test(timeout=60000)
398   public void testLogLevelByHttp() throws Exception {
399     testDynamicLogLevel(LogLevel.PROTOCOL_HTTP, LogLevel.PROTOCOL_HTTP, false);
400     try {
401       testDynamicLogLevel(LogLevel.PROTOCOL_HTTP, LogLevel.PROTOCOL_HTTPS, false);
402       fail("A HTTPS Client should not have succeeded in connecting to a HTTP server");
403     } catch (SSLException e) {
404       exceptionShouldContains(e, "Unrecognized SSL message");
405     }
406   }
407 
408   /**
409    * Server runs HTTP + SPNEGO.
410    *
411    * @throws Exception if http client can't access http server,
412    *   or http client can access https server.
413    */
414   @Test(timeout=60000)
415   public void testLogLevelByHttpWithSpnego() throws Exception {
416     testDynamicLogLevel(LogLevel.PROTOCOL_HTTP, LogLevel.PROTOCOL_HTTP, true);
417     try {
418       testDynamicLogLevel(LogLevel.PROTOCOL_HTTP, LogLevel.PROTOCOL_HTTPS, true);
419       fail("A HTTPS Client should not have succeeded in connecting to a HTTP server");
420     } catch (SSLException e) {
421       exceptionShouldContains(e, "Unrecognized SSL message");
422     }
423   }
424 
425   /**
426    * Server runs HTTPS, no SPNEGO.
427    *
428    * @throws Exception if https client can't access https server,
429    *   or https client can access http server.
430    */
431   @Test(timeout=60000)
432   public void testLogLevelByHttps() throws Exception {
433     testDynamicLogLevel(LogLevel.PROTOCOL_HTTPS, LogLevel.PROTOCOL_HTTPS, false);
434     try {
435       testDynamicLogLevel(LogLevel.PROTOCOL_HTTPS, LogLevel.PROTOCOL_HTTP, false);
436       fail("A HTTP Client should not have succeeded in connecting to a HTTPS server");
437     } catch (SocketException e) {
438       // Connection clean up and state management depends on the JVM and it's corresponding SSL
439       // libraries in use. We specifically noticed different behaviors with OpenJDK and Azul JVMs,
440       // See HBASE-26074.
441       exceptionShouldContains(e, "Unexpected end of file from server", "Connection reset");
442     }
443   }
444 
445   /**
446    * Server runs HTTPS + SPNEGO.
447    *
448    * @throws Exception if https client can't access https server,
449    *   or https client can access http server.
450    */
451   @Test(timeout=60000)
452   public void testLogLevelByHttpsWithSpnego() throws Exception {
453     testDynamicLogLevel(LogLevel.PROTOCOL_HTTPS, LogLevel.PROTOCOL_HTTPS, true);
454     try {
455       testDynamicLogLevel(LogLevel.PROTOCOL_HTTPS, LogLevel.PROTOCOL_HTTP,
456           true);
457       fail("A HTTP Client should not have succeeded in connecting to a " +
458           "HTTPS server");
459     }  catch (SocketException e) {
460       exceptionShouldContains(e, "Unexpected end of file from server", "Connection reset");
461     }
462   }
463 
464   /**
465    * Assert that a throwable or one of its causes should contain any of the substr in its message.
466    *
467    * Ideally we should use {@link GenericTestUtils#assertExceptionContains(String, Throwable)} util
468    * method which asserts t.toString() contains the substr. As the original throwable may have been
469    * wrapped in Hadoop3 because of HADOOP-12897, it's required to check all the wrapped causes.
470    * After stop supporting Hadoop2, this method can be removed and assertion in tests can use
471    * t.getCause() directly, similar to HADOOP-15280.
472    */
473   private static void exceptionShouldContains(Throwable throwable, String... substr) {
474     for (String s: substr) {
475       Throwable t = throwable;
476       while (t != null) {
477         String msg = t.toString();
478         if (msg != null && msg.toLowerCase().contains(s.toLowerCase())) {
479           return;
480         }
481         t = t.getCause();
482       }
483     }
484     String debug = "[" + Joiner.on(" , ").join(substr) + "]";
485     throw new AssertionError("Expected to find any of " + debug + " but got unexpected"
486       + " exception:" + StringUtils.stringifyException(throwable), throwable);
487   }
488 }