001/*
002 * Copyright (C) 2022-2023 The Prometheus jmx_exporter Authors
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package io.prometheus.jmx.common.http;
018
019import com.sun.net.httpserver.Authenticator;
020import com.sun.net.httpserver.HttpsConfigurator;
021import io.prometheus.jmx.common.configuration.ConvertToInteger;
022import io.prometheus.jmx.common.configuration.ConvertToMapAccessor;
023import io.prometheus.jmx.common.configuration.ConvertToString;
024import io.prometheus.jmx.common.configuration.ValidateIntegerInRange;
025import io.prometheus.jmx.common.configuration.ValidateStringIsNotBlank;
026import io.prometheus.jmx.common.http.authenticator.MessageDigestAuthenticator;
027import io.prometheus.jmx.common.http.authenticator.PBKDF2Authenticator;
028import io.prometheus.jmx.common.http.authenticator.PlaintextAuthenticator;
029import io.prometheus.jmx.common.http.ssl.SSLContextFactory;
030import io.prometheus.jmx.common.yaml.YamlMapAccessor;
031import io.prometheus.metrics.exporter.httpserver.HTTPServer;
032import io.prometheus.metrics.model.registry.PrometheusRegistry;
033import java.io.File;
034import java.io.FileReader;
035import java.io.IOException;
036import java.io.Reader;
037import java.net.InetAddress;
038import java.security.GeneralSecurityException;
039import java.util.HashMap;
040import java.util.HashSet;
041import java.util.Map;
042import java.util.Set;
043import java.util.concurrent.Executors;
044import java.util.concurrent.RejectedExecutionHandler;
045import java.util.concurrent.SynchronousQueue;
046import java.util.concurrent.ThreadFactory;
047import java.util.concurrent.ThreadPoolExecutor;
048import java.util.concurrent.TimeUnit;
049import java.util.concurrent.atomic.AtomicInteger;
050import org.yaml.snakeyaml.Yaml;
051
052/**
053 * Class to create the HTTPServer used by both the Java agent exporter and the standalone exporter
054 */
055public class HTTPServerFactory {
056
057    private static final int DEFAULT_MINIMUM_THREADS = 1;
058    private static final int DEFAULT_MAXIMUM_THREADS = 10;
059    private static final int DEFAULT_KEEP_ALIVE_TIME_SECONDS = 120;
060
061    private static final String REALM = "/";
062    private static final String PLAINTEXT = "plaintext";
063    private static final Set<String> SHA_ALGORITHMS;
064    private static final Set<String> PBKDF2_ALGORITHMS;
065    private static final Map<String, Integer> PBKDF2_ALGORITHM_ITERATIONS;
066    private static final String JAVAX_NET_SSL_KEY_STORE = "javax.net.ssl.keyStore";
067    private static final String JAVAX_NET_SSL_KEY_STORE_PASSWORD = "javax.net.ssl.keyStorePassword";
068
069    private static final int PBKDF2_KEY_LENGTH_BITS = 128;
070
071    static {
072        SHA_ALGORITHMS = new HashSet<>();
073        SHA_ALGORITHMS.add("SHA-1");
074        SHA_ALGORITHMS.add("SHA-256");
075        SHA_ALGORITHMS.add("SHA-512");
076
077        PBKDF2_ALGORITHMS = new HashSet<>();
078        PBKDF2_ALGORITHMS.add("PBKDF2WithHmacSHA1");
079        PBKDF2_ALGORITHMS.add("PBKDF2WithHmacSHA256");
080        PBKDF2_ALGORITHMS.add("PBKDF2WithHmacSHA512");
081
082        PBKDF2_ALGORITHM_ITERATIONS = new HashMap<>();
083        PBKDF2_ALGORITHM_ITERATIONS.put("PBKDF2WithHmacSHA1", 1300000);
084        PBKDF2_ALGORITHM_ITERATIONS.put("PBKDF2WithHmacSHA256", 600000);
085        PBKDF2_ALGORITHM_ITERATIONS.put("PBKDF2WithHmacSHA512", 210000);
086    }
087
088    private YamlMapAccessor rootYamlMapAccessor;
089
090    /** Constructor */
091    public HTTPServerFactory() {
092        // DO NOTHING
093    }
094
095    /**
096     * Method to create an HTTPServer using the supplied arguments
097     *
098     * @param inetAddress inetAddress
099     * @param port port
100     * @param prometheusRegistry prometheusRegistry
101     * @param exporterYamlFile exporterYamlFile
102     * @return an HTTPServer
103     * @throws IOException IOException
104     */
105    public HTTPServer createHTTPServer(
106            InetAddress inetAddress,
107            int port,
108            PrometheusRegistry prometheusRegistry,
109            File exporterYamlFile)
110            throws IOException {
111
112        HTTPServer.Builder httpServerBuilder =
113                HTTPServer.builder()
114                        .inetAddress(inetAddress)
115                        .port(port)
116                        .registry(prometheusRegistry);
117
118        createMapAccessor(exporterYamlFile);
119        configureThreads(httpServerBuilder);
120        configureAuthentication(httpServerBuilder);
121        configureSSL(httpServerBuilder);
122
123        return httpServerBuilder.buildAndStart();
124    }
125
126    /**
127     * Method to create a MapAccessor for accessing YAML configuration
128     *
129     * @param exporterYamlFile exporterYamlFile
130     */
131    private void createMapAccessor(File exporterYamlFile) {
132        try (Reader reader = new FileReader(exporterYamlFile)) {
133            Map<Object, Object> yamlMap = new Yaml().load(reader);
134            rootYamlMapAccessor = new YamlMapAccessor(yamlMap);
135        } catch (Throwable t) {
136            throw new ConfigurationException(
137                    String.format("Exception loading exporter YAML file [%s]", exporterYamlFile),
138                    t);
139        }
140    }
141
142    /**
143     * Method to configure the HTTPServer thread pool
144     *
145     * @param httpServerBuilder httpServerBuilder
146     */
147    private void configureThreads(HTTPServer.Builder httpServerBuilder) {
148        int minimum = DEFAULT_MINIMUM_THREADS;
149        int maximum = DEFAULT_MAXIMUM_THREADS;
150        int keepAliveTime = DEFAULT_KEEP_ALIVE_TIME_SECONDS;
151
152        if (rootYamlMapAccessor.containsPath("/httpServer/threads")) {
153            YamlMapAccessor httpServerThreadsMapAccessor =
154                    rootYamlMapAccessor
155                            .get("/httpServer/threads")
156                            .map(
157                                    new ConvertToMapAccessor(
158                                            ConfigurationException.supplier(
159                                                    "Invalid configuration for"
160                                                            + " /httpServer/threads")))
161                            .orElseThrow(
162                                    ConfigurationException.supplier(
163                                            "/httpServer/threads configuration values are"
164                                                    + " required"));
165
166            minimum =
167                    httpServerThreadsMapAccessor
168                            .get("/minimum")
169                            .map(
170                                    new ConvertToInteger(
171                                            ConfigurationException.supplier(
172                                                    "Invalid configuration for"
173                                                        + " /httpServer/threads/minimum must be an"
174                                                        + " integer")))
175                            .map(
176                                    new ValidateIntegerInRange(
177                                            0,
178                                            Integer.MAX_VALUE,
179                                            ConfigurationException.supplier(
180                                                    "Invalid configuration for"
181                                                        + " /httpServer/threads/minimum must be 0"
182                                                        + " or greater")))
183                            .orElseThrow(
184                                    ConfigurationException.supplier(
185                                            "/httpServer/threads/minimum is a required integer"));
186
187            maximum =
188                    httpServerThreadsMapAccessor
189                            .get("/maximum")
190                            .map(
191                                    new ConvertToInteger(
192                                            ConfigurationException.supplier(
193                                                    "Invalid configuration for"
194                                                        + " /httpServer/threads/maximum must be an"
195                                                        + " integer")))
196                            .map(
197                                    new ValidateIntegerInRange(
198                                            1,
199                                            Integer.MAX_VALUE,
200                                            ConfigurationException.supplier(
201                                                    "Invalid configuration for"
202                                                        + " /httpServer/threads/maxPoolSize must be"
203                                                        + " between greater than 0")))
204                            .orElseThrow(
205                                    ConfigurationException.supplier(
206                                            "/httpServer/threads/maximum is a required integer"));
207
208            keepAliveTime =
209                    httpServerThreadsMapAccessor
210                            .get("/keepAliveTime")
211                            .map(
212                                    new ConvertToInteger(
213                                            ConfigurationException.supplier(
214                                                    "Invalid configuration for"
215                                                        + " /httpServer/threads/keepAliveTime must"
216                                                        + " be an integer")))
217                            .map(
218                                    new ValidateIntegerInRange(
219                                            1,
220                                            Integer.MAX_VALUE,
221                                            ConfigurationException.supplier(
222                                                    "Invalid configuration for"
223                                                        + " /httpServer/threads/keepAliveTime must"
224                                                        + " be greater than 0")))
225                            .orElseThrow(
226                                    ConfigurationException.supplier(
227                                            "/httpServer/threads/keepAliveTime is a required"
228                                                    + " integer"));
229
230            if (maximum < minimum) {
231                throw new ConfigurationException(
232                        "/httpServer/threads/maximum must be greater than or equal to"
233                                + " /httpServer/threads/minimum");
234            }
235        }
236
237        ThreadPoolExecutor threadPoolExecutor =
238                new ThreadPoolExecutor(
239                        minimum,
240                        maximum,
241                        keepAliveTime,
242                        TimeUnit.SECONDS,
243                        new SynchronousQueue<>(true),
244                        NamedDaemonThreadFactory.defaultThreadFactory(true),
245                        new BlockingRejectedExecutionHandler());
246
247        httpServerBuilder.executorService(threadPoolExecutor);
248    }
249
250    /**
251     * Method to configure authentication
252     *
253     * @param httpServerBuilder httpServerBuilder
254     */
255    private void configureAuthentication(HTTPServer.Builder httpServerBuilder) {
256        if (rootYamlMapAccessor.containsPath("/httpServer/authentication")) {
257            YamlMapAccessor httpServerAuthenticationBasicYamlMapAccessor =
258                    rootYamlMapAccessor
259                            .get("/httpServer/authentication/basic")
260                            .map(
261                                    new ConvertToMapAccessor(
262                                            ConfigurationException.supplier(
263                                                    "Invalid configuration for"
264                                                            + " /httpServer/authentication/basic")))
265                            .orElseThrow(
266                                    ConfigurationException.supplier(
267                                            "/httpServer/authentication/basic configuration values"
268                                                    + " are required"));
269
270            String username =
271                    httpServerAuthenticationBasicYamlMapAccessor
272                            .get("/username")
273                            .map(
274                                    new ConvertToString(
275                                            ConfigurationException.supplier(
276                                                    "Invalid configuration for"
277                                                        + " /httpServer/authentication/basic/username"
278                                                        + " must be a string")))
279                            .map(
280                                    new ValidateStringIsNotBlank(
281                                            ConfigurationException.supplier(
282                                                    "Invalid configuration for"
283                                                        + " /httpServer/authentication/basic/username"
284                                                        + " must not be blank")))
285                            .orElseThrow(
286                                    ConfigurationException.supplier(
287                                            "/httpServer/authentication/basic/username is a"
288                                                    + " required string"));
289
290            String algorithm =
291                    httpServerAuthenticationBasicYamlMapAccessor
292                            .get("/algorithm")
293                            .map(
294                                    new ConvertToString(
295                                            ConfigurationException.supplier(
296                                                    "Invalid configuration for"
297                                                        + " /httpServer/authentication/basic/algorithm"
298                                                        + " must be a string")))
299                            .map(
300                                    new ValidateStringIsNotBlank(
301                                            ConfigurationException.supplier(
302                                                    "Invalid configuration for"
303                                                        + " /httpServer/authentication/basic/algorithm"
304                                                        + " must not be blank")))
305                            .orElse(PLAINTEXT);
306
307            Authenticator authenticator;
308
309            if (PLAINTEXT.equalsIgnoreCase(algorithm)) {
310                String password =
311                        httpServerAuthenticationBasicYamlMapAccessor
312                                .get("/password")
313                                .map(
314                                        new ConvertToString(
315                                                ConfigurationException.supplier(
316                                                        "Invalid configuration for"
317                                                            + " /httpServer/authentication/basic/password"
318                                                            + " must be a string")))
319                                .map(
320                                        new ValidateStringIsNotBlank(
321                                                ConfigurationException.supplier(
322                                                        "Invalid configuration for"
323                                                            + " /httpServer/authentication/basic/password"
324                                                            + " must not be blank")))
325                                .orElseThrow(
326                                        ConfigurationException.supplier(
327                                                "/httpServer/authentication/basic/password is a"
328                                                        + " required string"));
329
330                authenticator = new PlaintextAuthenticator("/", username, password);
331            } else if (SHA_ALGORITHMS.contains(algorithm)
332                    || PBKDF2_ALGORITHMS.contains(algorithm)) {
333                String hash =
334                        httpServerAuthenticationBasicYamlMapAccessor
335                                .get("/passwordHash")
336                                .map(
337                                        new ConvertToString(
338                                                ConfigurationException.supplier(
339                                                        "Invalid configuration for"
340                                                            + " /httpServer/authentication/basic/passwordHash"
341                                                            + " must be a string")))
342                                .map(
343                                        new ValidateStringIsNotBlank(
344                                                ConfigurationException.supplier(
345                                                        "Invalid configuration for"
346                                                            + " /httpServer/authentication/basic/passwordHash"
347                                                            + " must not be blank")))
348                                .orElseThrow(
349                                        ConfigurationException.supplier(
350                                                "/httpServer/authentication/basic/passwordHash is a"
351                                                        + " required string"));
352
353                if (SHA_ALGORITHMS.contains(algorithm)) {
354                    authenticator =
355                            createMessageDigestAuthenticator(
356                                    httpServerAuthenticationBasicYamlMapAccessor,
357                                    REALM,
358                                    username,
359                                    hash,
360                                    algorithm);
361                } else {
362                    authenticator =
363                            createPBKDF2Authenticator(
364                                    httpServerAuthenticationBasicYamlMapAccessor,
365                                    REALM,
366                                    username,
367                                    hash,
368                                    algorithm);
369                }
370            } else {
371                throw new ConfigurationException(
372                        String.format(
373                                "Unsupported /httpServer/authentication/basic/algorithm [%s]",
374                                algorithm));
375            }
376
377            httpServerBuilder.authenticator(authenticator);
378        }
379    }
380
381    /**
382     * Method to create a MessageDigestAuthenticator
383     *
384     * @param httpServerAuthenticationBasicYamlMapAccessor httpServerAuthenticationBasicMapAccessor
385     * @param realm realm
386     * @param username username
387     * @param password password
388     * @param algorithm algorithm
389     * @return a MessageDigestAuthenticator
390     */
391    private Authenticator createMessageDigestAuthenticator(
392            YamlMapAccessor httpServerAuthenticationBasicYamlMapAccessor,
393            String realm,
394            String username,
395            String password,
396            String algorithm) {
397        String salt =
398                httpServerAuthenticationBasicYamlMapAccessor
399                        .get("/salt")
400                        .map(
401                                new ConvertToString(
402                                        ConfigurationException.supplier(
403                                                "Invalid configuration for"
404                                                    + " /httpServer/authentication/basic/salt must"
405                                                    + " be a string")))
406                        .map(
407                                new ValidateStringIsNotBlank(
408                                        ConfigurationException.supplier(
409                                                "Invalid configuration for"
410                                                    + " /httpServer/authentication/basic/salt must"
411                                                    + " not be blank")))
412                        .orElseThrow(
413                                ConfigurationException.supplier(
414                                        "/httpServer/authentication/basic/salt is a required"
415                                                + " string"));
416
417        try {
418            return new MessageDigestAuthenticator(realm, username, password, algorithm, salt);
419        } catch (GeneralSecurityException e) {
420            throw new ConfigurationException(
421                    String.format(
422                            "Invalid /httpServer/authentication/basic/algorithm, unsupported"
423                                    + " algorithm [%s]",
424                            algorithm));
425        }
426    }
427
428    /**
429     * Method to create a PBKDF2Authenticator
430     *
431     * @param httpServerAuthenticationBasicYamlMapAccessor httpServerAuthenticationBasicMapAccessor
432     * @param realm realm
433     * @param username username
434     * @param password password
435     * @param algorithm algorithm
436     * @return a PBKDF2Authenticator
437     */
438    private Authenticator createPBKDF2Authenticator(
439            YamlMapAccessor httpServerAuthenticationBasicYamlMapAccessor,
440            String realm,
441            String username,
442            String password,
443            String algorithm) {
444        String salt =
445                httpServerAuthenticationBasicYamlMapAccessor
446                        .get("/salt")
447                        .map(
448                                new ConvertToString(
449                                        ConfigurationException.supplier(
450                                                "Invalid configuration for"
451                                                    + " /httpServer/authentication/basic/salt must"
452                                                    + " be a string")))
453                        .map(
454                                new ValidateStringIsNotBlank(
455                                        ConfigurationException.supplier(
456                                                "Invalid configuration for"
457                                                    + " /httpServer/authentication/basic/salt must"
458                                                    + " be not blank")))
459                        .orElseThrow(
460                                ConfigurationException.supplier(
461                                        "/httpServer/authentication/basic/salt is a required"
462                                                + " string"));
463
464        int iterations =
465                httpServerAuthenticationBasicYamlMapAccessor
466                        .get("/iterations")
467                        .map(
468                                new ConvertToInteger(
469                                        ConfigurationException.supplier(
470                                                "Invalid configuration for"
471                                                    + " /httpServer/authentication/basic/iterations"
472                                                    + " must be an integer")))
473                        .map(
474                                new ValidateIntegerInRange(
475                                        1,
476                                        Integer.MAX_VALUE,
477                                        ConfigurationException.supplier(
478                                                "Invalid configuration for"
479                                                    + " /httpServer/authentication/basic/iterations"
480                                                    + " must be between greater than 0")))
481                        .orElse(PBKDF2_ALGORITHM_ITERATIONS.get(algorithm));
482
483        int keyLength =
484                httpServerAuthenticationBasicYamlMapAccessor
485                        .get("/keyLength")
486                        .map(
487                                new ConvertToInteger(
488                                        ConfigurationException.supplier(
489                                                "Invalid configuration for"
490                                                    + " /httpServer/authentication/basic/keyLength"
491                                                    + " must be an integer")))
492                        .map(
493                                new ValidateIntegerInRange(
494                                        1,
495                                        Integer.MAX_VALUE,
496                                        ConfigurationException.supplier(
497                                                "Invalid configuration for"
498                                                    + " /httpServer/authentication/basic/keyLength"
499                                                    + " must be greater than 0")))
500                        .orElse(PBKDF2_KEY_LENGTH_BITS);
501
502        try {
503            return new PBKDF2Authenticator(
504                    realm, username, password, algorithm, salt, iterations, keyLength);
505        } catch (GeneralSecurityException e) {
506            throw new ConfigurationException(
507                    String.format(
508                            "Invalid /httpServer/authentication/basic/algorithm, unsupported"
509                                    + " algorithm [%s]",
510                            algorithm));
511        }
512    }
513
514    /**
515     * Method to configure SSL
516     *
517     * @param httpServerBuilder httpServerBuilder
518     */
519    public void configureSSL(HTTPServer.Builder httpServerBuilder) {
520        if (rootYamlMapAccessor.containsPath("/httpServer/ssl")) {
521            try {
522                String keyStoreFilename =
523                        rootYamlMapAccessor
524                                .get("/httpServer/ssl/keyStore/filename")
525                                .map(
526                                        new ConvertToString(
527                                                ConfigurationException.supplier(
528                                                        "Invalid configuration for"
529                                                            + " /httpServer/ssl/keyStore/filename"
530                                                            + " must be a string")))
531                                .map(
532                                        new ValidateStringIsNotBlank(
533                                                ConfigurationException.supplier(
534                                                        "Invalid configuration for"
535                                                            + " /httpServer/ssl/keyStore/filename"
536                                                            + " must not be blank")))
537                                .orElse(System.getProperty(JAVAX_NET_SSL_KEY_STORE));
538
539                String keyStorePassword =
540                        rootYamlMapAccessor
541                                .get("/httpServer/ssl/keyStore/password")
542                                .map(
543                                        new ConvertToString(
544                                                ConfigurationException.supplier(
545                                                        "Invalid configuration for"
546                                                            + " /httpServer/ssl/keyStore/password"
547                                                            + " must be a string")))
548                                .map(
549                                        new ValidateStringIsNotBlank(
550                                                ConfigurationException.supplier(
551                                                        "Invalid configuration for"
552                                                            + " /httpServer/ssl/keyStore/password"
553                                                            + " must not be blank")))
554                                .orElse(System.getProperty(JAVAX_NET_SSL_KEY_STORE_PASSWORD));
555
556                String certificateAlias =
557                        rootYamlMapAccessor
558                                .get("/httpServer/ssl/certificate/alias")
559                                .map(
560                                        new ConvertToString(
561                                                ConfigurationException.supplier(
562                                                        "Invalid configuration for"
563                                                            + " /httpServer/ssl/certificate/alias"
564                                                            + " must be a string")))
565                                .map(
566                                        new ValidateStringIsNotBlank(
567                                                ConfigurationException.supplier(
568                                                        "Invalid configuration for"
569                                                            + " /httpServer/ssl/certificate/alias"
570                                                            + " must not be blank")))
571                                .orElseThrow(
572                                        ConfigurationException.supplier(
573                                                "/httpServer/ssl/certificate/alias is a required"
574                                                        + " string"));
575
576                httpServerBuilder.httpsConfigurator(
577                        new HttpsConfigurator(
578                                SSLContextFactory.createSSLContext(
579                                        keyStoreFilename, keyStorePassword, certificateAlias)));
580            } catch (GeneralSecurityException | IOException e) {
581                String message = e.getMessage();
582                if (message != null && !message.trim().isEmpty()) {
583                    message = ", " + message.trim();
584                } else {
585                    message = "";
586                }
587
588                throw new ConfigurationException(
589                        String.format("Exception loading SSL configuration%s", message), e);
590            }
591        }
592    }
593
594    /**
595     * Class to implement a named thread factory
596     *
597     * <p>Copied from the `prometheus/client_java` `HTTPServer` due to scoping issues / dependencies
598     */
599    private static class NamedDaemonThreadFactory implements ThreadFactory {
600
601        private static final AtomicInteger POOL_NUMBER = new AtomicInteger(1);
602
603        private final int poolNumber = POOL_NUMBER.getAndIncrement();
604        private final AtomicInteger threadNumber = new AtomicInteger(1);
605        private final ThreadFactory delegate;
606        private final boolean daemon;
607
608        NamedDaemonThreadFactory(ThreadFactory delegate, boolean daemon) {
609            this.delegate = delegate;
610            this.daemon = daemon;
611        }
612
613        @Override
614        public Thread newThread(Runnable r) {
615            Thread t = delegate.newThread(r);
616            t.setName(
617                    String.format(
618                            "prometheus-http-%d-%d", poolNumber, threadNumber.getAndIncrement()));
619            t.setDaemon(daemon);
620            return t;
621        }
622
623        static ThreadFactory defaultThreadFactory(boolean daemon) {
624            return new NamedDaemonThreadFactory(Executors.defaultThreadFactory(), daemon);
625        }
626    }
627
628    /** Class to implement a blocking RejectedExecutionHandler */
629    private static class BlockingRejectedExecutionHandler implements RejectedExecutionHandler {
630
631        @Override
632        public void rejectedExecution(Runnable runnable, ThreadPoolExecutor threadPoolExecutor) {
633            if (!threadPoolExecutor.isShutdown()) {
634                try {
635                    threadPoolExecutor.getQueue().put(runnable);
636                } catch (InterruptedException e) {
637                    // DO NOTHING
638                }
639            }
640        }
641    }
642}