001/**
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.apache.hadoop.hbase.http.log;
019
020import static org.junit.Assert.assertEquals;
021import static org.junit.Assert.assertFalse;
022import static org.junit.Assert.assertNotEquals;
023import static org.junit.Assert.assertTrue;
024import static org.junit.Assert.fail;
025import java.io.File;
026import java.net.BindException;
027import java.net.SocketException;
028import java.net.URI;
029import java.security.PrivilegedExceptionAction;
030import java.util.Properties;
031import javax.net.ssl.SSLException;
032import org.apache.commons.io.FileUtils;
033import org.apache.hadoop.HadoopIllegalArgumentException;
034import org.apache.hadoop.conf.Configuration;
035import org.apache.hadoop.fs.CommonConfigurationKeys;
036import org.apache.hadoop.fs.CommonConfigurationKeysPublic;
037import org.apache.hadoop.fs.FileUtil;
038import org.apache.hadoop.hbase.HBaseClassTestRule;
039import org.apache.hadoop.hbase.HBaseCommonTestingUtility;
040import org.apache.hadoop.hbase.http.HttpConfig;
041import org.apache.hadoop.hbase.http.HttpServer;
042import org.apache.hadoop.hbase.http.log.LogLevel.CLI;
043import org.apache.hadoop.hbase.http.ssl.KeyStoreTestUtil;
044import org.apache.hadoop.hbase.testclassification.MiscTests;
045import org.apache.hadoop.hbase.testclassification.SmallTests;
046import org.apache.hadoop.hdfs.DFSConfigKeys;
047import org.apache.hadoop.minikdc.MiniKdc;
048import org.apache.hadoop.net.NetUtils;
049import org.apache.hadoop.security.UserGroupInformation;
050import org.apache.hadoop.security.authorize.AccessControlList;
051import org.apache.hadoop.security.ssl.SSLFactory;
052import org.apache.hadoop.test.GenericTestUtils;
053import org.apache.hadoop.util.StringUtils;
054import org.apache.log4j.Level;
055import org.apache.log4j.LogManager;
056import org.apache.log4j.Logger;
057import org.junit.AfterClass;
058import org.junit.BeforeClass;
059import org.junit.ClassRule;
060import org.junit.Test;
061import org.junit.experimental.categories.Category;
062
063/**
064 * Test LogLevel.
065 */
066@Category({MiscTests.class, SmallTests.class})
067public class TestLogLevel {
068  @ClassRule
069  public static final HBaseClassTestRule CLASS_RULE =
070      HBaseClassTestRule.forClass(TestLogLevel.class);
071
072  private static String keystoresDir;
073  private static String sslConfDir;
074  private static Configuration serverConf;
075  private static Configuration clientConf;
076  private static Configuration sslConf;
077  private static final String logName = TestLogLevel.class.getName();
078  private static final Logger log = LogManager.getLogger(logName);
079  private final static String PRINCIPAL = "loglevel.principal";
080  private final static String KEYTAB  = "loglevel.keytab";
081
082  private static MiniKdc kdc;
083
084  private static final String LOCALHOST = "localhost";
085  private static final String clientPrincipal = "client/" + LOCALHOST;
086  private static String HTTP_PRINCIPAL = "HTTP/" + LOCALHOST;
087  private static HBaseCommonTestingUtility HTU;
088  private static File keyTabFile;
089
090  @BeforeClass
091  public static void setUp() throws Exception {
092    serverConf = new Configuration();
093    HTU = new HBaseCommonTestingUtility(serverConf);
094
095    File keystoreDir = new File(HTU.getDataTestDir("keystore").toString());
096    keystoreDir.mkdirs();
097    keyTabFile = new File(HTU.getDataTestDir("keytab").toString(), "keytabfile");
098    keyTabFile.getParentFile().mkdirs();
099    clientConf = new Configuration();
100
101    setupSSL(keystoreDir);
102
103    kdc = setupMiniKdc();
104    // Create two principles: a client and an HTTP principal
105    kdc.createPrincipal(keyTabFile, clientPrincipal, HTTP_PRINCIPAL);
106  }
107
108  /**
109   * Sets up {@link MiniKdc} for testing security.
110   * Copied from HBaseTestingUtility#setupMiniKdc().
111   */
112  static private MiniKdc setupMiniKdc() throws Exception {
113    Properties conf = MiniKdc.createConf();
114    conf.put(MiniKdc.DEBUG, true);
115    MiniKdc kdc = null;
116    File dir = null;
117    // There is time lag between selecting a port and trying to bind with it. It's possible that
118    // another service captures the port in between which'll result in BindException.
119    boolean bindException;
120    int numTries = 0;
121    do {
122      try {
123        bindException = false;
124        dir = new File(HTU.getDataTestDir("kdc").toUri().getPath());
125        kdc = new MiniKdc(conf, dir);
126        kdc.start();
127      } catch (BindException e) {
128        FileUtils.deleteDirectory(dir);  // clean directory
129        numTries++;
130        if (numTries == 3) {
131          log.error("Failed setting up MiniKDC. Tried " + numTries + " times.");
132          throw e;
133        }
134        log.error("BindException encountered when setting up MiniKdc. Trying again.");
135        bindException = true;
136      }
137    } while (bindException);
138    return kdc;
139  }
140
141  static private void setupSSL(File base) throws Exception {
142    clientConf.set(DFSConfigKeys.DFS_HTTP_POLICY_KEY, HttpConfig.Policy.HTTPS_ONLY.name());
143    clientConf.set(DFSConfigKeys.DFS_NAMENODE_HTTPS_ADDRESS_KEY, "localhost:0");
144    clientConf.set(DFSConfigKeys.DFS_DATANODE_HTTPS_ADDRESS_KEY, "localhost:0");
145
146    keystoresDir = base.getAbsolutePath();
147    sslConfDir = KeyStoreTestUtil.getClasspathDir(TestLogLevel.class);
148    KeyStoreTestUtil.setupSSLConfig(keystoresDir, sslConfDir, serverConf, false);
149
150    sslConf = getSslConfig(serverConf);
151  }
152
153  /**
154   * Get the SSL configuration.
155   * This method is copied from KeyStoreTestUtil#getSslConfig() in Hadoop.
156   * @return {@link Configuration} instance with ssl configs loaded.
157   * @param conf to pull client/server SSL settings filename from
158   */
159  private static Configuration getSslConfig(Configuration conf){
160    Configuration sslConf = new Configuration(false);
161    String sslServerConfFile = conf.get(SSLFactory.SSL_SERVER_CONF_KEY);
162    String sslClientConfFile =  conf.get(SSLFactory.SSL_CLIENT_CONF_KEY);
163    sslConf.addResource(sslServerConfFile);
164    sslConf.addResource(sslClientConfFile);
165    sslConf.set(SSLFactory.SSL_SERVER_CONF_KEY, sslServerConfFile);
166    sslConf.set(SSLFactory.SSL_CLIENT_CONF_KEY, sslClientConfFile);
167    return sslConf;
168  }
169
170  @AfterClass
171  public static void tearDown() {
172    if (kdc != null) {
173      kdc.stop();
174    }
175
176    FileUtil.fullyDelete(new File(HTU.getDataTestDir().toString()));
177  }
178
179  /**
180   * Test client command line options. Does not validate server behavior.
181   * @throws Exception if commands return unexpected results.
182   */
183  @Test
184  public void testCommandOptions() throws Exception {
185    final String className = this.getClass().getName();
186
187    assertFalse(validateCommand(new String[] {"-foo" }));
188    // fail due to insufficient number of arguments
189    assertFalse(validateCommand(new String[] {}));
190    assertFalse(validateCommand(new String[] {"-getlevel" }));
191    assertFalse(validateCommand(new String[] {"-setlevel" }));
192    assertFalse(validateCommand(new String[] {"-getlevel", "foo.bar:8080" }));
193
194    // valid command arguments
195    assertTrue(validateCommand(
196        new String[] {"-getlevel", "foo.bar:8080", className }));
197    assertTrue(validateCommand(
198        new String[] {"-setlevel", "foo.bar:8080", className, "DEBUG" }));
199    assertTrue(validateCommand(
200        new String[] {"-getlevel", "foo.bar:8080", className }));
201    assertTrue(validateCommand(
202        new String[] {"-setlevel", "foo.bar:8080", className, "DEBUG" }));
203
204    // fail due to the extra argument
205    assertFalse(validateCommand(
206        new String[] {"-getlevel", "foo.bar:8080", className, "blah" }));
207    assertFalse(validateCommand(
208        new String[] {"-setlevel", "foo.bar:8080", className, "DEBUG", "blah" }));
209    assertFalse(validateCommand(
210        new String[] {"-getlevel", "foo.bar:8080", className, "-setlevel", "foo.bar:8080",
211          className }));
212  }
213
214  /**
215   * Check to see if a command can be accepted.
216   *
217   * @param args a String array of arguments
218   * @return true if the command can be accepted, false if not.
219   */
220  private boolean validateCommand(String[] args) {
221    CLI cli = new CLI(clientConf);
222    try {
223      cli.parseArguments(args);
224    } catch (HadoopIllegalArgumentException e) {
225      return false;
226    } catch (Exception e) {
227      // this is used to verify the command arguments only.
228      // no HadoopIllegalArgumentException = the arguments are good.
229      return true;
230    }
231    return true;
232  }
233
234  /**
235   * Creates and starts a Jetty server binding at an ephemeral port to run
236   * LogLevel servlet.
237   * @param protocol "http" or "https"
238   * @param isSpnego true if SPNEGO is enabled
239   * @return a created HttpServer object
240   * @throws Exception if unable to create or start a Jetty server
241   */
242  private HttpServer createServer(String protocol, boolean isSpnego)
243      throws Exception {
244    HttpServer.Builder builder = new HttpServer.Builder()
245        .setName("..")
246        .addEndpoint(new URI(protocol + "://localhost:0"))
247        .setFindPort(true)
248        .setConf(serverConf);
249    if (isSpnego) {
250      // Set up server Kerberos credentials.
251      // Since the server may fall back to simple authentication,
252      // use ACL to make sure the connection is Kerberos/SPNEGO authenticated.
253      builder.setSecurityEnabled(true)
254          .setUsernameConfKey(PRINCIPAL)
255          .setKeytabConfKey(KEYTAB)
256          .setACL(new AccessControlList("client"));
257    }
258
259    // if using HTTPS, configure keystore/truststore properties.
260    if (protocol.equals(LogLevel.PROTOCOL_HTTPS)) {
261      builder = builder.
262          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)
278      throws Exception {
279    testDynamicLogLevel(bindProtocol, connectProtocol, isSpnego, Level.DEBUG.toString());
280  }
281
282  /**
283   * Run both client and server using the given protocol.
284   *
285   * @param bindProtocol specify either http or https for server
286   * @param connectProtocol specify either http or https for client
287   * @param isSpnego true if SPNEGO is enabled
288   * @throws Exception if client can't accesss server.
289   */
290  private void testDynamicLogLevel(final String bindProtocol, final String connectProtocol,
291      final boolean isSpnego, final String newLevel)
292      throws Exception {
293    if (!LogLevel.isValidProtocol(bindProtocol)) {
294      throw new Exception("Invalid server protocol " + bindProtocol);
295    }
296    if (!LogLevel.isValidProtocol(connectProtocol)) {
297      throw new Exception("Invalid client protocol " + connectProtocol);
298    }
299    Level oldLevel = log.getEffectiveLevel();
300    assertNotEquals("Get default Log Level which shouldn't be ERROR.",
301        Level.ERROR, oldLevel);
302
303    // configs needed for SPNEGO at server side
304    if (isSpnego) {
305      serverConf.set(PRINCIPAL, HTTP_PRINCIPAL);
306      serverConf.set(KEYTAB, keyTabFile.getAbsolutePath());
307      serverConf.set(CommonConfigurationKeysPublic.HADOOP_SECURITY_AUTHENTICATION, "kerberos");
308      serverConf.setBoolean(CommonConfigurationKeys.HADOOP_SECURITY_AUTHORIZATION, true);
309      UserGroupInformation.setConfiguration(serverConf);
310    } else {
311      serverConf.set(CommonConfigurationKeysPublic.HADOOP_SECURITY_AUTHENTICATION, "simple");
312      serverConf.setBoolean(CommonConfigurationKeys.HADOOP_SECURITY_AUTHORIZATION, false);
313      UserGroupInformation.setConfiguration(serverConf);
314    }
315
316    final HttpServer server = createServer(bindProtocol, isSpnego);
317    // get server port
318    final String authority = NetUtils.getHostPortString(server.getConnectorAddress(0));
319
320    String keytabFilePath = keyTabFile.getAbsolutePath();
321
322    UserGroupInformation clientUGI = UserGroupInformation.
323        loginUserFromKeytabAndReturnUGI(clientPrincipal, keytabFilePath);
324    try {
325      clientUGI.doAs((PrivilegedExceptionAction<Void>) () -> {
326        // client command line
327        getLevel(connectProtocol, authority);
328        setLevel(connectProtocol, authority, newLevel);
329        return null;
330      });
331    } finally {
332      clientUGI.logoutUserFromKeytab();
333      server.stop();
334    }
335
336    // restore log level
337    GenericTestUtils.setLogLevel(log, oldLevel);
338  }
339
340  /**
341   * Run LogLevel command line to start a client to get log level of this test
342   * class.
343   *
344   * @param protocol specify either http or https
345   * @param authority daemon's web UI address
346   * @throws Exception if unable to connect
347   */
348  private void getLevel(String protocol, String authority) throws Exception {
349    String[] getLevelArgs = {"-getlevel", authority, logName, "-protocol", protocol};
350    CLI cli = new CLI(protocol.equalsIgnoreCase("https") ? sslConf : clientConf);
351    cli.run(getLevelArgs);
352  }
353
354  /**
355   * Run LogLevel command line to start a client to set log level of this test
356   * class to debug.
357   *
358   * @param protocol specify either http or https
359   * @param authority daemon's web UI address
360   * @throws Exception if unable to run or log level does not change as expected
361   */
362  private void setLevel(String protocol, String authority, String newLevel)
363      throws Exception {
364    String[] setLevelArgs = {"-setlevel", authority, logName, newLevel, "-protocol", protocol};
365    CLI cli = new CLI(protocol.equalsIgnoreCase("https") ? sslConf : clientConf);
366    cli.run(setLevelArgs);
367
368    assertEquals("new level not equal to expected: ", newLevel.toUpperCase(),
369        log.getEffectiveLevel().toString());
370  }
371
372  /**
373   * Test setting log level to "Info".
374   *
375   * @throws Exception if client can't set log level to INFO.
376   */
377  @Test
378  public void testInfoLogLevel() throws Exception {
379    testDynamicLogLevel(LogLevel.PROTOCOL_HTTP, LogLevel.PROTOCOL_HTTP, true, "INFO");
380  }
381
382  /**
383   * Test setting log level to "Error".
384   *
385   * @throws Exception if client can't set log level to ERROR.
386   */
387  @Test
388  public void testErrorLogLevel() throws Exception {
389    testDynamicLogLevel(LogLevel.PROTOCOL_HTTP, LogLevel.PROTOCOL_HTTP, true, "ERROR");
390  }
391
392  /**
393   * Server runs HTTP, no SPNEGO.
394   *
395   * @throws Exception if http client can't access http server,
396   *   or http client can access https server.
397   */
398  @Test
399  public void testLogLevelByHttp() throws Exception {
400    testDynamicLogLevel(LogLevel.PROTOCOL_HTTP, LogLevel.PROTOCOL_HTTP, false);
401    try {
402      testDynamicLogLevel(LogLevel.PROTOCOL_HTTP, LogLevel.PROTOCOL_HTTPS,
403          false);
404      fail("An HTTPS Client should not have succeeded in connecting to a " +
405          "HTTP server");
406    } catch (SSLException e) {
407      exceptionShouldContains("Unrecognized SSL message", e);
408    }
409  }
410
411  /**
412   * Server runs HTTP + SPNEGO.
413   *
414   * @throws Exception if http client can't access http server,
415   *   or http client can access https server.
416   */
417  @Test
418  public void testLogLevelByHttpWithSpnego() throws Exception {
419    testDynamicLogLevel(LogLevel.PROTOCOL_HTTP, LogLevel.PROTOCOL_HTTP, true);
420    try {
421      testDynamicLogLevel(LogLevel.PROTOCOL_HTTP, LogLevel.PROTOCOL_HTTPS,
422          true);
423      fail("An HTTPS Client should not have succeeded in connecting to a " +
424          "HTTP server");
425    } catch (SSLException e) {
426      exceptionShouldContains("Unrecognized SSL message", e);
427    }
428  }
429
430  /**
431   * Server runs HTTPS, no SPNEGO.
432   *
433   * @throws Exception if https client can't access https server,
434   *   or https client can access http server.
435   */
436  @Test
437  public void testLogLevelByHttps() throws Exception {
438    testDynamicLogLevel(LogLevel.PROTOCOL_HTTPS, LogLevel.PROTOCOL_HTTPS,
439        false);
440    try {
441      testDynamicLogLevel(LogLevel.PROTOCOL_HTTPS, LogLevel.PROTOCOL_HTTP,
442          false);
443      fail("An HTTP Client should not have succeeded in connecting to a " +
444          "HTTPS server");
445    } catch (SocketException e) {
446      exceptionShouldContains("Unexpected end of file from server", e);
447    }
448  }
449
450  /**
451   * Server runs HTTPS + SPNEGO.
452   *
453   * @throws Exception if https client can't access https server,
454   *   or https client can access http server.
455   */
456  @Test
457  public void testLogLevelByHttpsWithSpnego() throws Exception {
458    testDynamicLogLevel(LogLevel.PROTOCOL_HTTPS, LogLevel.PROTOCOL_HTTPS,
459        true);
460    try {
461      testDynamicLogLevel(LogLevel.PROTOCOL_HTTPS, LogLevel.PROTOCOL_HTTP,
462          true);
463      fail("An HTTP Client should not have succeeded in connecting to a " +
464          "HTTPS server");
465    }  catch (SocketException e) {
466      exceptionShouldContains("Unexpected end of file from server", e);
467    }
468  }
469
470  /**
471   * Assert that a throwable or one of its causes should contain the substr in its message.
472   *
473   * Ideally we should use {@link GenericTestUtils#assertExceptionContains(String, Throwable)} util
474   * method which asserts t.toString() contains the substr. As the original throwable may have been
475   * wrapped in Hadoop3 because of HADOOP-12897, it's required to check all the wrapped causes.
476   * After stop supporting Hadoop2, this method can be removed and assertion in tests can use
477   * t.getCause() directly, similar to HADOOP-15280.
478   */
479  private static void exceptionShouldContains(String substr, Throwable throwable) {
480    Throwable t = throwable;
481    while (t != null) {
482      String msg = t.toString();
483      if (msg != null && msg.toLowerCase().contains(substr.toLowerCase())) {
484        return;
485      }
486      t = t.getCause();
487    }
488    throw new AssertionError("Expected to find '" + substr + "' but got unexpected exception:" +
489        StringUtils.stringifyException(throwable), throwable);
490  }
491}