001package io.prometheus.metrics.exporter.httpserver;
002
003import com.sun.net.httpserver.Authenticator;
004import com.sun.net.httpserver.HttpContext;
005import com.sun.net.httpserver.HttpExchange;
006import com.sun.net.httpserver.HttpHandler;
007import com.sun.net.httpserver.HttpServer;
008import com.sun.net.httpserver.HttpsConfigurator;
009import com.sun.net.httpserver.HttpsServer;
010import io.prometheus.metrics.config.PrometheusProperties;
011import io.prometheus.metrics.model.registry.PrometheusRegistry;
012import java.io.Closeable;
013import java.io.IOException;
014import java.io.InputStream;
015import java.net.InetAddress;
016import java.net.InetSocketAddress;
017import java.security.PrivilegedActionException;
018import java.security.PrivilegedExceptionAction;
019import java.util.concurrent.ExecutionException;
020import java.util.concurrent.ExecutorService;
021import java.util.concurrent.SynchronousQueue;
022import java.util.concurrent.ThreadPoolExecutor;
023import java.util.concurrent.TimeUnit;
024import javax.annotation.Nullable;
025import javax.security.auth.Subject;
026
027/**
028 * Expose Prometheus metrics using a plain Java HttpServer.
029 *
030 * <p>Example Usage:
031 *
032 * <pre>{@code
033 * HTTPServer server = HTTPServer.builder()
034 *     .port(9090)
035 *     .buildAndStart();
036 * }</pre>
037 */
038public class HTTPServer implements Closeable {
039
040  static {
041    if (!System.getProperties().containsKey("sun.net.httpserver.maxReqTime")) {
042      System.setProperty("sun.net.httpserver.maxReqTime", "60");
043    }
044
045    if (!System.getProperties().containsKey("sun.net.httpserver.maxRspTime")) {
046      System.setProperty("sun.net.httpserver.maxRspTime", "600");
047    }
048  }
049
050  protected final HttpServer server;
051  protected final ExecutorService executorService;
052
053  private HTTPServer(
054      PrometheusProperties config,
055      ExecutorService executorService,
056      HttpServer httpServer,
057      PrometheusRegistry registry,
058      @Nullable Authenticator authenticator,
059      @Nullable String authenticatedSubjectAttributeName,
060      @Nullable HttpHandler defaultHandler,
061      @Nullable String metricsHandlerPath,
062      @Nullable Boolean registerHealthHandler) {
063    if (httpServer.getAddress() == null) {
064      throw new IllegalArgumentException("HttpServer hasn't been bound to an address");
065    }
066    this.server = httpServer;
067    this.executorService = executorService;
068    String metricsPath = getMetricsPath(metricsHandlerPath);
069    try {
070      server.removeContext("/");
071    } catch (IllegalArgumentException e) {
072      // context "/" not registered yet, ignore
073    }
074    registerHandler(
075        "/",
076        defaultHandler == null ? new DefaultHandler(metricsPath) : defaultHandler,
077        authenticator,
078        authenticatedSubjectAttributeName);
079    try {
080      server.removeContext(metricsPath);
081    } catch (IllegalArgumentException e) {
082      // context metricsPath not registered yet, ignore
083    }
084    registerHandler(
085        metricsPath,
086        new MetricsHandler(config, registry),
087        authenticator,
088        authenticatedSubjectAttributeName);
089    if (registerHealthHandler == null || registerHealthHandler) {
090      registerHandler(
091          "/-/healthy", new HealthyHandler(), authenticator, authenticatedSubjectAttributeName);
092    }
093    try {
094      // HttpServer.start() starts the HttpServer in a new background thread.
095      // If we call HttpServer.start() from a thread of the executorService,
096      // the background thread will inherit the "daemon" property,
097      // i.e. the server will run as a Daemon thread.
098      // See https://github.com/prometheus/client_java/pull/955
099      this.executorService.submit(this.server::start).get();
100      // calling .get() on the Future here to avoid silently discarding errors
101    } catch (InterruptedException | ExecutionException e) {
102      throw new RuntimeException(e);
103    }
104  }
105
106  private String getMetricsPath(@Nullable String metricsHandlerPath) {
107    if (metricsHandlerPath == null) {
108      return "/metrics";
109    }
110    if (!metricsHandlerPath.startsWith("/")) {
111      return "/" + metricsHandlerPath;
112    }
113    return metricsHandlerPath;
114  }
115
116  private void registerHandler(
117      String path,
118      HttpHandler handler,
119      @Nullable Authenticator authenticator,
120      @Nullable String subjectAttributeName) {
121    HttpContext context = server.createContext(path, wrapWithDoAs(handler, subjectAttributeName));
122    if (authenticator != null) {
123      context.setAuthenticator(authenticator);
124    }
125  }
126
127  private HttpHandler wrapWithDoAs(HttpHandler handler, @Nullable String subjectAttributeName) {
128    if (subjectAttributeName == null) {
129      return handler;
130    }
131
132    // invoke handler using the subject.doAs from the named attribute
133    return new HttpHandler() {
134      @Override
135      public void handle(HttpExchange exchange) throws IOException {
136        Object authSubject = exchange.getAttribute(subjectAttributeName);
137        if (authSubject instanceof Subject) {
138          try {
139            Subject.doAs(
140                (Subject) authSubject,
141                (PrivilegedExceptionAction<IOException>)
142                    () -> {
143                      handler.handle(exchange);
144                      return null;
145                    });
146          } catch (PrivilegedActionException e) {
147            if (e.getException() != null) {
148              throw new IOException(e.getException());
149            } else {
150              throw new IOException(e);
151            }
152          }
153        } else {
154          drainInputAndClose(exchange);
155          exchange.sendResponseHeaders(403, -1);
156        }
157      }
158    };
159  }
160
161  private void drainInputAndClose(HttpExchange httpExchange) throws IOException {
162    InputStream inputStream = httpExchange.getRequestBody();
163    byte[] b = new byte[4096];
164    while (inputStream.read(b) != -1) {
165      // nop
166    }
167    inputStream.close();
168  }
169
170  /** Stop the HTTP server. Same as {@link #close()}. */
171  public void stop() {
172    close();
173  }
174
175  /** Stop the HTTPServer. Same as {@link #stop()}. */
176  @Override
177  public void close() {
178    server.stop(0);
179    executorService.shutdown(); // Free any (parked/idle) threads in pool
180  }
181
182  /**
183   * Gets the port number. This is useful if you did not specify a port and the server picked a free
184   * port automatically.
185   */
186  public int getPort() {
187    return server.getAddress().getPort();
188  }
189
190  public static Builder builder() {
191    return new Builder(PrometheusProperties.get());
192  }
193
194  public static Builder builder(PrometheusProperties config) {
195    return new Builder(config);
196  }
197
198  public static class Builder {
199
200    private final PrometheusProperties config;
201    @Nullable private Integer port = null;
202    @Nullable private String hostname = null;
203    @Nullable private InetAddress inetAddress = null;
204    @Nullable private ExecutorService executorService = null;
205    @Nullable private PrometheusRegistry registry = null;
206    @Nullable private Authenticator authenticator = null;
207    @Nullable private String authenticatedSubjectAttributeName = null;
208    @Nullable private HttpsConfigurator httpsConfigurator = null;
209    @Nullable private HttpHandler defaultHandler = null;
210    @Nullable private String metricsHandlerPath = null;
211    @Nullable private Boolean registerHealthHandler = null;
212
213    private Builder(PrometheusProperties config) {
214      this.config = config;
215    }
216
217    /**
218     * Port to bind to. Default is 0, indicating that a random port will be selected. You can learn
219     * the randomly selected port by calling {@link HTTPServer#getPort()}.
220     */
221    public Builder port(int port) {
222      this.port = port;
223      return this;
224    }
225
226    /**
227     * Use this hostname to resolve the IP address to bind to. Must not be called together with
228     * {@link #inetAddress(InetAddress)}. Default is empty, indicating that the HTTPServer binds to
229     * the wildcard address.
230     */
231    public Builder hostname(String hostname) {
232      this.hostname = hostname;
233      return this;
234    }
235
236    /**
237     * Bind to this IP address. Must not be called together with {@link #hostname(String)}. Default
238     * is empty, indicating that the HTTPServer binds to the wildcard address.
239     */
240    public Builder inetAddress(InetAddress address) {
241      this.inetAddress = address;
242      return this;
243    }
244
245    /** Optional: ExecutorService used by the {@code httpServer}. */
246    public Builder executorService(ExecutorService executorService) {
247      this.executorService = executorService;
248      return this;
249    }
250
251    /** Optional: Default is {@link PrometheusRegistry#defaultRegistry}. */
252    public Builder registry(PrometheusRegistry registry) {
253      this.registry = registry;
254      return this;
255    }
256
257    /** Optional: {@link Authenticator} for authentication. */
258    public Builder authenticator(Authenticator authenticator) {
259      this.authenticator = authenticator;
260      return this;
261    }
262
263    /** Optional: the attribute name of a Subject from a custom authenticator. */
264    public Builder authenticatedSubjectAttributeName(String authenticatedSubjectAttributeName) {
265      this.authenticatedSubjectAttributeName = authenticatedSubjectAttributeName;
266      return this;
267    }
268
269    /** Optional: {@link HttpsConfigurator} for TLS/SSL */
270    public Builder httpsConfigurator(HttpsConfigurator configurator) {
271      this.httpsConfigurator = configurator;
272      return this;
273    }
274
275    /**
276     * Optional: Override default handler, i.e. the handler that will be registered for the /
277     * endpoint.
278     */
279    public Builder defaultHandler(HttpHandler defaultHandler) {
280      this.defaultHandler = defaultHandler;
281      return this;
282    }
283
284    /** Optional: Override default path for the metrics endpoint. Default is {@code /metrics}. */
285    public Builder metricsHandlerPath(String metricsHandlerPath) {
286      this.metricsHandlerPath = metricsHandlerPath;
287      return this;
288    }
289
290    /** Optional: Override if the health handler should be registered. Default is {@code true}. */
291    public Builder registerHealthHandler(boolean registerHealthHandler) {
292      this.registerHealthHandler = registerHealthHandler;
293      return this;
294    }
295
296    /** Build and start the HTTPServer. */
297    public HTTPServer buildAndStart() throws IOException {
298      if (registry == null) {
299        registry = PrometheusRegistry.defaultRegistry;
300      }
301      HttpServer httpServer;
302      if (httpsConfigurator != null) {
303        httpServer = HttpsServer.create(makeInetSocketAddress(), 3);
304        ((HttpsServer) httpServer).setHttpsConfigurator(httpsConfigurator);
305      } else {
306        httpServer = HttpServer.create(makeInetSocketAddress(), 3);
307      }
308      ExecutorService executorService = makeExecutorService();
309      httpServer.setExecutor(executorService);
310      return new HTTPServer(
311          config,
312          executorService,
313          httpServer,
314          registry,
315          authenticator,
316          authenticatedSubjectAttributeName,
317          defaultHandler,
318          metricsHandlerPath,
319          registerHealthHandler);
320    }
321
322    private InetSocketAddress makeInetSocketAddress() {
323      if (inetAddress != null) {
324        assertNull(hostname, "cannot configure 'inetAddress' and 'hostname' at the same time");
325        return new InetSocketAddress(inetAddress, findPort());
326      } else if (hostname != null) {
327        return new InetSocketAddress(hostname, findPort());
328      } else {
329        return new InetSocketAddress(findPort());
330      }
331    }
332
333    private ExecutorService makeExecutorService() {
334      if (executorService != null) {
335        return executorService;
336      } else {
337        return new ThreadPoolExecutor(
338            1,
339            10,
340            120,
341            TimeUnit.SECONDS,
342            new SynchronousQueue<>(true),
343            NamedDaemonThreadFactory.defaultThreadFactory(true),
344            new BlockingRejectedExecutionHandler());
345      }
346    }
347
348    private int findPort() {
349      if (config != null && config.getExporterHttpServerProperties() != null) {
350        Integer port = config.getExporterHttpServerProperties().getPort();
351        if (port != null) {
352          return port;
353        }
354      }
355      if (port != null) {
356        return port;
357      }
358      return 0; // random port will be selected
359    }
360
361    private void assertNull(@Nullable Object o, String msg) {
362      if (o != null) {
363        throw new IllegalStateException(msg);
364      }
365    }
366  }
367}