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