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 java.io.BufferedReader;
021import java.io.FileNotFoundException;
022import java.io.IOException;
023import java.io.InputStreamReader;
024import java.io.PrintWriter;
025import java.net.HttpURLConnection;
026import java.net.URL;
027import java.nio.charset.StandardCharsets;
028import java.util.Objects;
029import java.util.regex.Pattern;
030import javax.net.ssl.HttpsURLConnection;
031import javax.net.ssl.SSLSocketFactory;
032import javax.servlet.ServletException;
033import javax.servlet.http.HttpServlet;
034import javax.servlet.http.HttpServletRequest;
035import javax.servlet.http.HttpServletResponse;
036import org.apache.hadoop.HadoopIllegalArgumentException;
037import org.apache.hadoop.conf.Configuration;
038import org.apache.hadoop.conf.Configured;
039import org.apache.hadoop.hbase.http.HttpServer;
040import org.apache.hadoop.hbase.logging.Log4jUtils;
041import org.apache.hadoop.security.authentication.client.AuthenticatedURL;
042import org.apache.hadoop.security.authentication.client.KerberosAuthenticator;
043import org.apache.hadoop.security.ssl.SSLFactory;
044import org.apache.hadoop.util.ServletUtil;
045import org.apache.hadoop.util.Tool;
046import org.apache.yetus.audience.InterfaceAudience;
047import org.slf4j.Logger;
048import org.slf4j.LoggerFactory;
049
050/**
051 * Change log level in runtime.
052 */
053@InterfaceAudience.Private
054public final class LogLevel {
055  private static final String USAGES = "\nUsage: General options are:\n"
056    + "\t[-getlevel <host:port> <classname> [-protocol (http|https)]\n"
057    + "\t[-setlevel <host:port> <classname> <level> [-protocol (http|https)]";
058
059  public static final String PROTOCOL_HTTP = "http";
060  public static final String PROTOCOL_HTTPS = "https";
061
062  public static final String READONLY_LOGGERS_CONF_KEY = "hbase.ui.logLevels.readonly.loggers";
063
064  /**
065   * A command line implementation
066   */
067  public static void main(String[] args) throws Exception {
068    CLI cli = new CLI(new Configuration());
069    System.exit(cli.run(args));
070  }
071
072  /**
073   * Valid command line options.
074   */
075  private enum Operations {
076    GETLEVEL,
077    SETLEVEL,
078    UNKNOWN
079  }
080
081  private static void printUsage() {
082    System.err.println(USAGES);
083    System.exit(-1);
084  }
085
086  public static boolean isValidProtocol(String protocol) {
087    return protocol.equals(PROTOCOL_HTTP) || protocol.equals(PROTOCOL_HTTPS);
088  }
089
090  static class CLI extends Configured implements Tool {
091    private Operations operation = Operations.UNKNOWN;
092    private String protocol;
093    private String hostName;
094    private String className;
095    private String level;
096
097    CLI(Configuration conf) {
098      setConf(conf);
099    }
100
101    @Override
102    public int run(String[] args) throws Exception {
103      try {
104        parseArguments(args);
105        sendLogLevelRequest();
106      } catch (HadoopIllegalArgumentException e) {
107        printUsage();
108      }
109      return 0;
110    }
111
112    /**
113     * Send HTTP request to the daemon.
114     * @throws HadoopIllegalArgumentException if arguments are invalid.
115     * @throws Exception                      if unable to connect
116     */
117    private void sendLogLevelRequest() throws HadoopIllegalArgumentException, Exception {
118      switch (operation) {
119        case GETLEVEL:
120          doGetLevel();
121          break;
122        case SETLEVEL:
123          doSetLevel();
124          break;
125        default:
126          throw new HadoopIllegalArgumentException("Expect either -getlevel or -setlevel");
127      }
128    }
129
130    public void parseArguments(String[] args) throws HadoopIllegalArgumentException {
131      if (args.length == 0) {
132        throw new HadoopIllegalArgumentException("No arguments specified");
133      }
134      int nextArgIndex = 0;
135      while (nextArgIndex < args.length) {
136        switch (args[nextArgIndex]) {
137          case "-getlevel":
138            nextArgIndex = parseGetLevelArgs(args, nextArgIndex);
139            break;
140          case "-setlevel":
141            nextArgIndex = parseSetLevelArgs(args, nextArgIndex);
142            break;
143          case "-protocol":
144            nextArgIndex = parseProtocolArgs(args, nextArgIndex);
145            break;
146          default:
147            throw new HadoopIllegalArgumentException("Unexpected argument " + args[nextArgIndex]);
148        }
149      }
150
151      // if operation is never specified in the arguments
152      if (operation == Operations.UNKNOWN) {
153        throw new HadoopIllegalArgumentException("Must specify either -getlevel or -setlevel");
154      }
155
156      // if protocol is unspecified, set it as http.
157      if (protocol == null) {
158        protocol = PROTOCOL_HTTP;
159      }
160    }
161
162    private int parseGetLevelArgs(String[] args, int index) throws HadoopIllegalArgumentException {
163      // fail if multiple operations are specified in the arguments
164      if (operation != Operations.UNKNOWN) {
165        throw new HadoopIllegalArgumentException("Redundant -getlevel command");
166      }
167      // check number of arguments is sufficient
168      if (index + 2 >= args.length) {
169        throw new HadoopIllegalArgumentException("-getlevel needs two parameters");
170      }
171      operation = Operations.GETLEVEL;
172      hostName = args[index + 1];
173      className = args[index + 2];
174      return index + 3;
175    }
176
177    private int parseSetLevelArgs(String[] args, int index) throws HadoopIllegalArgumentException {
178      // fail if multiple operations are specified in the arguments
179      if (operation != Operations.UNKNOWN) {
180        throw new HadoopIllegalArgumentException("Redundant -setlevel command");
181      }
182      // check number of arguments is sufficient
183      if (index + 3 >= args.length) {
184        throw new HadoopIllegalArgumentException("-setlevel needs three parameters");
185      }
186      operation = Operations.SETLEVEL;
187      hostName = args[index + 1];
188      className = args[index + 2];
189      level = args[index + 3];
190      return index + 4;
191    }
192
193    private int parseProtocolArgs(String[] args, int index) throws HadoopIllegalArgumentException {
194      // make sure only -protocol is specified
195      if (protocol != null) {
196        throw new HadoopIllegalArgumentException("Redundant -protocol command");
197      }
198      // check number of arguments is sufficient
199      if (index + 1 >= args.length) {
200        throw new HadoopIllegalArgumentException("-protocol needs one parameter");
201      }
202      // check protocol is valid
203      protocol = args[index + 1];
204      if (!isValidProtocol(protocol)) {
205        throw new HadoopIllegalArgumentException("Invalid protocol: " + protocol);
206      }
207      return index + 2;
208    }
209
210    /**
211     * Send HTTP request to get log level.
212     * @throws HadoopIllegalArgumentException if arguments are invalid.
213     * @throws Exception                      if unable to connect
214     */
215    private void doGetLevel() throws Exception {
216      process(protocol + "://" + hostName + "/logLevel?log=" + className);
217    }
218
219    /**
220     * Send HTTP request to set log level.
221     * @throws HadoopIllegalArgumentException if arguments are invalid.
222     * @throws Exception                      if unable to connect
223     */
224    private void doSetLevel() throws Exception {
225      process(protocol + "://" + hostName + "/logLevel?log=" + className + "&level=" + level);
226    }
227
228    /**
229     * Connect to the URL. Supports HTTP and supports SPNEGO authentication. It falls back to simple
230     * authentication if it fails to initiate SPNEGO.
231     * @param url the URL address of the daemon servlet
232     * @return a connected connection
233     * @throws Exception if it can not establish a connection.
234     */
235    private HttpURLConnection connect(URL url) throws Exception {
236      AuthenticatedURL.Token token = new AuthenticatedURL.Token();
237      AuthenticatedURL aUrl;
238      SSLFactory clientSslFactory;
239      HttpURLConnection connection;
240      // If https is chosen, configures SSL client.
241      if (PROTOCOL_HTTPS.equals(url.getProtocol())) {
242        clientSslFactory = new SSLFactory(SSLFactory.Mode.CLIENT, this.getConf());
243        clientSslFactory.init();
244        SSLSocketFactory sslSocketF = clientSslFactory.createSSLSocketFactory();
245
246        aUrl = new AuthenticatedURL(new KerberosAuthenticator(), clientSslFactory);
247        connection = aUrl.openConnection(url, token);
248        HttpsURLConnection httpsConn = (HttpsURLConnection) connection;
249        httpsConn.setSSLSocketFactory(sslSocketF);
250      } else {
251        aUrl = new AuthenticatedURL(new KerberosAuthenticator());
252        connection = aUrl.openConnection(url, token);
253      }
254      connection.connect();
255      return connection;
256    }
257
258    /**
259     * Configures the client to send HTTP request to the URL. Supports SPENGO for authentication.
260     * @param urlString URL and query string to the daemon's web UI
261     * @throws Exception if unable to connect
262     */
263    private void process(String urlString) throws Exception {
264      URL url = new URL(urlString);
265      System.out.println("Connecting to " + url);
266
267      HttpURLConnection connection = connect(url);
268
269      // We now use the validateResponse method of hbase to handle for HTML response,
270      // as with Jetty 12: getResponseMessage() returns "Precondition Failed" vs
271      // "Modification of logger protected.org.apache.hadoop.hbase.http.log.TestLogLevel is
272      // disallowed in configuration" in Jetty 9
273      LogLevelExceptionUtils.validateResponse(connection, 200);
274
275      // read from the servlet
276
277      try (
278        InputStreamReader streamReader =
279          new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8);
280        BufferedReader bufferedReader = new BufferedReader(streamReader)) {
281        bufferedReader.lines().filter(Objects::nonNull).filter(line -> line.startsWith(MARKER))
282          .forEach(line -> System.out.println(TAG.matcher(line).replaceAll("")));
283      } catch (IOException ioe) {
284        System.err.println("" + ioe);
285      }
286    }
287  }
288
289  private static final String MARKER = "<!-- OUTPUT -->";
290  private static final Pattern TAG = Pattern.compile("<[^>]*>");
291
292  /**
293   * A servlet implementation
294   */
295  @InterfaceAudience.Private
296  public static class Servlet extends HttpServlet {
297    private static final long serialVersionUID = 1L;
298
299    @Override
300    public void doGet(HttpServletRequest request, HttpServletResponse response)
301      throws ServletException, IOException {
302      // Do the authorization
303      if (!HttpServer.hasAdministratorAccess(getServletContext(), request, response)) {
304        return;
305      }
306      // Disallow modification of the LogLevel if explicitly set to readonly
307      Configuration conf =
308        (Configuration) getServletContext().getAttribute(HttpServer.CONF_CONTEXT_ATTRIBUTE);
309      if (conf.getBoolean("hbase.master.ui.readonly", false)) {
310        sendError(response, HttpServletResponse.SC_FORBIDDEN,
311          "Modification of HBase via the UI is disallowed in configuration.");
312        return;
313      }
314      response.setContentType("text/html");
315      PrintWriter out;
316      try {
317        String headerPath = "header.jsp?pageTitle=Log Level";
318        request.getRequestDispatcher(headerPath).include(request, response);
319        out = response.getWriter();
320      } catch (FileNotFoundException e) {
321        // in case file is not found fall back to old design
322        out = ServletUtil.initHTML(response, "Log Level");
323      }
324      out.println(FORMS);
325
326      String logName = ServletUtil.getParameter(request, "log");
327      String level = ServletUtil.getParameter(request, "level");
328
329      String[] readOnlyLogLevels = conf.getStrings(READONLY_LOGGERS_CONF_KEY);
330
331      if (logName != null) {
332        out.println("<h2>Results</h2>");
333        out.println(MARKER + "Submitted Log Name: <b>" + logName + "</b><br />");
334
335        Logger log = LoggerFactory.getLogger(logName);
336        out.println(MARKER + "Log Class: <b>" + log.getClass().getName() + "</b><br />");
337        if (level != null) {
338          if (!isLogLevelChangeAllowed(logName, readOnlyLogLevels)) {
339            sendError(response, HttpServletResponse.SC_PRECONDITION_FAILED,
340              "Modification of logger " + logName + " is disallowed in configuration.");
341            return;
342          }
343
344          out.println(MARKER + "Submitted Level: <b>" + level + "</b><br />");
345        }
346        process(log, level, out);
347      }
348
349      try {
350        String footerPath = "footer.jsp";
351        out.println("</div>");
352        request.getRequestDispatcher(footerPath).include(request, response);
353      } catch (FileNotFoundException e) {
354        out.println(ServletUtil.HTML_TAIL);
355      }
356      out.close();
357    }
358
359    private boolean isLogLevelChangeAllowed(String logger, String[] readOnlyLogLevels) {
360      if (readOnlyLogLevels == null) {
361        return true;
362      }
363      for (String readOnlyLogLevel : readOnlyLogLevels) {
364        if (logger.startsWith(readOnlyLogLevel)) {
365          return false;
366        }
367      }
368      return true;
369    }
370
371    private void sendError(HttpServletResponse response, int code, String message)
372      throws IOException {
373      response.setStatus(code, message);
374      response.sendError(code, message);
375    }
376
377    static final String FORMS = "<div class='container-fluid content'>\n"
378      + "<div class='row inner_header top_header'>\n" + "<div class='page-header'>\n"
379      + "<h1>Get/Set Log Level</h1>\n" + "</div>\n" + "</div>\n" + "\n" + "<h2>Actions</h2>\n"
380      + "\n" + "<div class='row mb-4'>\n" + "<div class='col'>\n"
381      + "<form class='row g-3 align-items-center justify-content-center'>\n"
382      + "<div class='col-sm-auto'>\n"
383      + "<button type='submit' class='btn btn-primary'>Get Log Level</button>\n" + "</div>\n"
384      + "  <div class='col-sm-auto'>\n"
385      + "<input type='text' name='log' class='form-control' size='50'"
386      + " required='required' placeholder='Log Name (required)'>\n" + "</div>\n"
387      + "  <div class='col-sm-auto'>\n"
388      + "<span>Gets the current log level for the specified log name.</span>\n" + "</div>\n"
389      + "</form>\n" + "</div>\n" + "</div>\n" + "\n" + "<div class='row'>\n" + "<div class='col'>\n"
390      + "\n" + "<form class='row g-3 align-items-center justify-content-center'>\n"
391      + "<div class='col-sm-auto'>\n"
392      + "<button type='submit' class='btn btn-primary'>Set Log Level</button>\n" + "</div>\n"
393      + "<div class='col-sm-auto'>\n"
394      + "<input type='text' name='log' class='form-control mb-2' size='50'"
395      + " required='required' placeholder='Log Name (required)'>\n"
396      + "<input type='text' name='level' class='form-control' size='50'"
397      + " required='required' placeholder='Log Level (required)'>\n" + "</div>\n"
398      + "<div class='col-sm-auto'>\n"
399      + "<span>Sets the specified log level for the specified log name.</span>\n" + "</div>\n"
400      + "</form>\n" + "\n" + "</div>\n" + "</div>" + "<hr>\n";
401
402    private static void process(Logger logger, String levelName, PrintWriter out) {
403      if (levelName != null) {
404        try {
405          Log4jUtils.setLogLevel(logger.getName(), levelName);
406          out.println(MARKER + "<div class='text-success'>" + "Setting Level to <strong>"
407            + levelName + "</strong> ...<br />" + "</div>");
408        } catch (IllegalArgumentException e) {
409          out.println(MARKER + "<div class='text-danger'>" + "Bad level : <strong>" + levelName
410            + "</strong><br />" + "</div>");
411        }
412      }
413      out.println(MARKER + "Effective level: <b>" + Log4jUtils.getEffectiveLevel(logger.getName())
414        + "</b><br />");
415    }
416  }
417
418  private LogLevel() {
419  }
420}