JettyAdminServerTest.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.zookeeper.server.admin;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.SocketException;
import java.net.URL;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.Security;
import java.security.cert.X509Certificate;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSession;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import org.apache.zookeeper.PortAssignment;
import org.apache.zookeeper.ZKTestCase;
import org.apache.zookeeper.common.KeyStoreFileType;
import org.apache.zookeeper.common.SecretUtilsTest;
import org.apache.zookeeper.common.X509Exception.SSLContextException;
import org.apache.zookeeper.common.X509KeyType;
import org.apache.zookeeper.common.X509TestContext;
import org.apache.zookeeper.server.ZooKeeperServerMainTest;
import org.apache.zookeeper.server.admin.AdminServer.AdminServerException;
import org.apache.zookeeper.server.quorum.QuorumPeerTestBase;
import org.apache.zookeeper.test.ClientBase;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class JettyAdminServerTest extends ZKTestCase {

    protected static final Logger LOG = LoggerFactory.getLogger(JettyAdminServerTest.class);

    static final String URL_FORMAT = "http://localhost:%d/commands";
    static final String HTTPS_URL_FORMAT = "https://localhost:%d/commands";
    private final int jettyAdminPort = PortAssignment.unique();

    @BeforeEach
    public void enableServer() {
        // Override setting in ZKTestCase
        System.setProperty("zookeeper.admin.enableServer", "true");
        System.setProperty("zookeeper.admin.serverPort", "" + jettyAdminPort);
    }

    @BeforeEach
    public void setupEncryption(@TempDir File tempDir) {
        Security.addProvider(new BouncyCastleProvider());
        X509TestContext x509TestContext = null;
        try {
            x509TestContext = X509TestContext.newBuilder()
                                             .setTempDir(tempDir)
                                             .setKeyStorePassword("")
                                             .setKeyStoreKeyType(X509KeyType.EC)
                                             .setTrustStorePassword("")
                                             .setTrustStoreKeyType(X509KeyType.EC)
                                             .build();
            System.setProperty(
                "zookeeper.ssl.quorum.keyStore.location",
                x509TestContext.getKeyStoreFile(KeyStoreFileType.PEM).getAbsolutePath());
            System.setProperty(
                "zookeeper.ssl.quorum.trustStore.location",
                x509TestContext.getTrustStoreFile(KeyStoreFileType.PEM).getAbsolutePath());
        } catch (Exception e) {
            LOG.info("Problems encountered while setting up encryption for Jetty admin server test", e);
        }
        System.setProperty("zookeeper.ssl.quorum.keyStore.password", "");
        System.setProperty("zookeeper.ssl.quorum.keyStore.type", "PEM");
        System.setProperty("zookeeper.ssl.quorum.trustStore.password", "");
        System.setProperty("zookeeper.ssl.quorum.trustStore.type", "PEM");
        System.setProperty("zookeeper.admin.portUnification", "true");

        // Create a trust manager that does not validate certificate chains
        TrustManager[] trustAllCerts = new TrustManager[]{new X509TrustManager() {
            public java.security.cert.X509Certificate[] getAcceptedIssuers() {
                return null;
            }
            public void checkClientTrusted(X509Certificate[] certs, String authType) {
            }
            public void checkServerTrusted(X509Certificate[] certs, String authType) {
            }
        }};

        // Create all-trusting trust manager
        SSLContext sc = null;
        try {
            sc = SSLContext.getInstance("SSL");
            sc.init(null, trustAllCerts, new java.security.SecureRandom());
        } catch (Exception e) {
            LOG.error("Failed to customize encryption for HTTPS", e);
        }

        // Create all-trusting hostname verifier
        HostnameVerifier allValid = new HostnameVerifier() {
            public boolean verify(String hostname, SSLSession session) {
                return true;
            }
        };

        // This is a temporary fix while we do not yet have certificates set up to make
        // HTTPS requests correctly. This is equivalent to the "-k" option in curl.
        HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
        HttpsURLConnection.setDefaultHostnameVerifier(allValid);
    }

    @AfterEach
    public void cleanUp() {
        Security.removeProvider("BC");

        System.clearProperty("zookeeper.admin.enableServer");
        System.clearProperty("zookeeper.admin.serverPort");

        System.clearProperty("zookeeper.ssl.quorum.keyStore.location");
        System.clearProperty("zookeeper.ssl.quorum.keyStore.password");
        System.clearProperty("zookeeper.ssl.quorum.keyStore.passwordPath");
        System.clearProperty("zookeeper.ssl.quorum.keyStore.type");
        System.clearProperty("zookeeper.ssl.quorum.trustStore.location");
        System.clearProperty("zookeeper.ssl.quorum.trustStore.password");
        System.clearProperty("zookeeper.ssl.quorum.trustStore.passwordPath");
        System.clearProperty("zookeeper.ssl.quorum.trustStore.type");
        System.clearProperty("zookeeper.admin.portUnification");
        System.clearProperty("zookeeper.admin.forceHttps");
    }

    /**
     * Tests that we can start and query a JettyAdminServer.
     */
    @Test
    public void testJettyAdminServer() throws AdminServerException, IOException, SSLContextException, GeneralSecurityException {
        JettyAdminServer server = new JettyAdminServer();
        try {
            server.start();
            queryAdminServer(jettyAdminPort);
            traceAdminServer(jettyAdminPort);
        } finally {
            server.shutdown();
        }
    }

    /**
     * Starts a standalone server and tests that we can query its AdminServer.
     */
    @Test
    public void testStandalone() throws Exception {
        ClientBase.setupTestEnv();

        final int CLIENT_PORT = PortAssignment.unique();

        ZooKeeperServerMainTest.MainThread main = new ZooKeeperServerMainTest.MainThread(CLIENT_PORT, false, null);
        main.start();

        assertTrue(ClientBase.waitForServerUp("127.0.0.1:" + CLIENT_PORT, ClientBase.CONNECTION_TIMEOUT),
                "waiting for server being up");

        queryAdminServer(jettyAdminPort);

        main.shutdown();

        assertTrue(ClientBase.waitForServerDown("127.0.0.1:" + CLIENT_PORT, ClientBase.CONNECTION_TIMEOUT),
                "waiting for server down");
    }

    /**
     * Starts a quorum of two servers and tests that we can query both AdminServers.
     */
    @Test
    public void testQuorum() throws Exception {
        ClientBase.setupTestEnv();

        final int CLIENT_PORT_QP1 = PortAssignment.unique();
        final int CLIENT_PORT_QP2 = PortAssignment.unique();

        final int ADMIN_SERVER_PORT1 = PortAssignment.unique();
        final int ADMIN_SERVER_PORT2 = PortAssignment.unique();

        String quorumCfgSection = String.format(
            "server.1=127.0.0.1:%d:%d;%d\nserver.2=127.0.0.1:%d:%d;%d",
            PortAssignment.unique(),
            PortAssignment.unique(),
            CLIENT_PORT_QP1,
            PortAssignment.unique(),
            PortAssignment.unique(),
            CLIENT_PORT_QP2);
        QuorumPeerTestBase.MainThread q1 = new QuorumPeerTestBase.MainThread(1, CLIENT_PORT_QP1, ADMIN_SERVER_PORT1, quorumCfgSection, null);
        q1.start();

        // Since JettyAdminServer reads a system property to determine its port,
        // make sure it initializes itself before setting the system property
        // again with the second port number
        Thread.sleep(500);

        QuorumPeerTestBase.MainThread q2 = new QuorumPeerTestBase.MainThread(2, CLIENT_PORT_QP2, ADMIN_SERVER_PORT2, quorumCfgSection, null);
        q2.start();

        Thread.sleep(500);

        assertTrue(ClientBase.waitForServerUp("127.0.0.1:" + CLIENT_PORT_QP1, ClientBase.CONNECTION_TIMEOUT),
                "waiting for server 1 being up");
        assertTrue(ClientBase.waitForServerUp("127.0.0.1:" + CLIENT_PORT_QP2, ClientBase.CONNECTION_TIMEOUT),
                "waiting for server 2 being up");

        queryAdminServer(ADMIN_SERVER_PORT1);
        queryAdminServer(ADMIN_SERVER_PORT2);

        q1.shutdown();
        q2.shutdown();

        assertTrue(ClientBase.waitForServerDown("127.0.0.1:" + CLIENT_PORT_QP1, ClientBase.CONNECTION_TIMEOUT),
                "waiting for server 1 down");
        assertTrue(ClientBase.waitForServerDown("127.0.0.1:" + CLIENT_PORT_QP2, ClientBase.CONNECTION_TIMEOUT),
                "waiting for server 2 down");
    }

    @Test
    public void testForceHttpsPortUnificationEnabled() throws Exception {
        testForceHttps(true);
    }

    @Test
    public void testForceHttpsPortUnificationDisabled() throws Exception {
        testForceHttps(false);
    }

    @Test
    public void testForceHttps_withWrongPasswordFromFile() throws Exception {
        final Path secretFile = SecretUtilsTest.createSecretFile("" + "wrong");

        System.setProperty("zookeeper.ssl.quorum.keyStore.passwordPath", secretFile.toString());
        System.setProperty("zookeeper.ssl.quorum.trustStore.passwordPath", secretFile.toString());

        assertThrows(IOException.class, () -> testForceHttps(false));
    }

    private void testForceHttps(boolean portUnification) throws Exception {
        System.setProperty("zookeeper.admin.forceHttps", "true");
        System.setProperty("zookeeper.admin.portUnification", String.valueOf(portUnification));
        boolean httpsPassed = false;

        JettyAdminServer server = new JettyAdminServer();
        try {
            server.start();
            queryAdminServer(String.format(HTTPS_URL_FORMAT, jettyAdminPort), true);
            httpsPassed = true;
            queryAdminServer(String.format(URL_FORMAT, jettyAdminPort), false);
            fail("http call should have failed since forceHttps=true");
        } catch (SocketException se) {
            //good
        } finally {
            server.shutdown();
        }
        assertTrue(httpsPassed);
    }

    /**
     * Check that we can load the commands page of an AdminServer running at
     * localhost:port. (Note that this should work even if no zk server is set.)
     */
    private void queryAdminServer(int port) throws IOException, SSLContextException {
        queryAdminServer(String.format(URL_FORMAT, port), false);
        queryAdminServer(String.format(HTTPS_URL_FORMAT, port), true);
    }

    /**
     * Check that loading urlStr results in a non-zero length response.
     */
    private void queryAdminServer(String urlStr, boolean encrypted) throws IOException, SSLContextException {
        URL url = new URL(urlStr);
        BufferedReader dis;
        if (!encrypted) {
            dis = new BufferedReader(new InputStreamReader((url.openStream())));
        } else {
            HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
            dis = new BufferedReader(new InputStreamReader(conn.getInputStream()));
        }
        String line = dis.readLine();
        assertTrue(line.length() > 0);
    }

    /**
     * Using TRACE method to visit admin server
     */
    private void traceAdminServer(int port) throws IOException {
      traceAdminServer(String.format(URL_FORMAT, port));
      traceAdminServer(String.format(HTTPS_URL_FORMAT, port));
    }

    /**
     * Using TRACE method to visit admin server, the response should be 403 forbidden
     */
    private void traceAdminServer(String urlStr) throws IOException {
        HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection();
        conn.setRequestMethod("TRACE");
        conn.connect();
        assertEquals(HttpURLConnection.HTTP_FORBIDDEN, conn.getResponseCode());
    }
}