//
// ========================================================================
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
//
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
// ========================================================================
//

package org.eclipse.jetty.jmx;

import java.net.ConnectException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.HashMap;
import java.util.Map;
import javax.management.remote.JMXConnector;
import javax.management.remote.JMXConnectorFactory;
import javax.management.remote.JMXServiceURL;
import javax.rmi.ssl.SslRMIClientSocketFactory;

import org.eclipse.jetty.toolchain.test.MavenTestingUtils;
import org.eclipse.jetty.util.component.LifeCycle;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledOnOs;
import org.junit.jupiter.api.condition.OS;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.matchesRegex;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

/**
 * Running the tests of this class in the same JVM results often in
 * <pre>
 * Caused by: java.rmi.NoSuchObjectException: no such object in table
 *     at sun.rmi.transport.StreamRemoteCall.exceptionReceivedFromServer(StreamRemoteCall.java:276)
 *     at sun.rmi.transport.StreamRemoteCall.executeCall(StreamRemoteCall.java:253)
 *     at sun.rmi.server.UnicastRef.invoke(UnicastRef.java:379)
 *     at sun.rmi.registry.RegistryImpl_Stub.bind(Unknown Source)
 * </pre>
 * Running each test method in a forked JVM makes these tests all pass,
 * therefore the issue is likely caused by use of stale stubs cached by the JDK.
 */
public class ConnectorServerTest
{
    private String objectName = "org.eclipse.jetty:name=rmiconnectorserver";
    private ConnectorServer connectorServer;

    @AfterEach
    public void tearDown() throws Exception
    {
        LifeCycle.stop(connectorServer);
    }

    @Test
    public void testAddressAfterStart() throws Exception
    {
        connectorServer = new ConnectorServer(new JMXServiceURL("service:jmx:rmi:///jndi/rmi:///jmxrmi"), objectName);
        connectorServer.start();

        JMXServiceURL address = connectorServer.getAddress();
        assertThat(address.toString(), matchesRegex("service:jmx:rmi://[^:]+(:\\d+)?/jndi/rmi://[^:]+:\\d+/jmxrmi"));
    }

    @Test
    public void testNoRegistryHostBindsToHost() throws Exception
    {
        connectorServer = new ConnectorServer(new JMXServiceURL("service:jmx:rmi:///jndi/rmi:///jmxrmi"), objectName);
        connectorServer.start();

        // Verify that I can connect to the RMI registry using a non-loopback address.
        new Socket(InetAddress.getLocalHost(), 1099).close();
        assertThrows(ConnectException.class, () ->
        {
            // Verify that I cannot connect to the RMI registry using the loopback address.
            new Socket(InetAddress.getLoopbackAddress(), 1099).close();
        });
    }

    @Test
    public void testNoRegistryHostNonDefaultRegistryPort() throws Exception
    {
        ServerSocket serverSocket = new ServerSocket(0);
        int registryPort = serverSocket.getLocalPort();
        serverSocket.close();
        connectorServer = new ConnectorServer(new JMXServiceURL("service:jmx:rmi:///jndi/rmi://:" + registryPort + "/jmxrmi"), objectName);
        connectorServer.start();

        // Verify that I can connect to the RMI registry using a non-loopback address.
        new Socket(InetAddress.getLocalHost(), registryPort).close();
        assertThrows(ConnectException.class, () ->
        {
            // Verify that I cannot connect to the RMI registry using the loopback address.
            new Socket(InetAddress.getLoopbackAddress(), registryPort).close();
        });
    }

    @Test
    public void testAnyRegistryHostBindsToAny() throws Exception
    {
        ServerSocket serverSocket = new ServerSocket(0);
        int registryPort = serverSocket.getLocalPort();
        serverSocket.close();
        connectorServer = new ConnectorServer(new JMXServiceURL("service:jmx:rmi:///jndi/rmi://0.0.0.0:" + registryPort + "/jmxrmi"), objectName);
        connectorServer.start();

        // Verify that I can connect to the RMI registry using a non-loopback address.
        new Socket(InetAddress.getLocalHost(), registryPort).close();
        // Verify that I can connect to the RMI registry using the loopback address.
        new Socket(InetAddress.getLoopbackAddress(), registryPort).close();
    }

    @Test
    public void testLocalhostRegistryBindsToLoopback() throws Exception
    {
        connectorServer = new ConnectorServer(new JMXServiceURL("service:jmx:rmi:///jndi/rmi://localhost:1099/jmxrmi"), objectName);
        connectorServer.start();

        InetAddress localHost = InetAddress.getLocalHost();
        if (!localHost.isLoopbackAddress())
        {
            assertThrows(ConnectException.class, () ->
            {
                // Verify that I cannot connect to the RMIRegistry using a non-loopback address.
                new Socket(localHost, 1099);
            });
        }

        InetAddress loopback = InetAddress.getLoopbackAddress();
        new Socket(loopback, 1099).close();
    }

    @Test
    public void testNoRMIHostBindsToHost() throws Exception
    {
        connectorServer = new ConnectorServer(new JMXServiceURL("service:jmx:rmi:///jndi/rmi:///jmxrmi"), objectName);
        connectorServer.start();

        // Verify that I can connect to the RMI server using a non-loopback address.
        new Socket(InetAddress.getLocalHost(), connectorServer.getAddress().getPort()).close();
        assertThrows(ConnectException.class, () ->
        {
            // Verify that I cannot connect to the RMI server using the loopback address.
            new Socket(InetAddress.getLoopbackAddress(), connectorServer.getAddress().getPort()).close();
        });
    }

    @Test
    public void testAnyRMIHostBindsToAny() throws Exception
    {
        connectorServer = new ConnectorServer(new JMXServiceURL("service:jmx:rmi://0.0.0.0/jndi/rmi:///jmxrmi"), objectName);
        connectorServer.start();

        // Verify that I can connect to the RMI server using a non-loopback address.
        new Socket(InetAddress.getLocalHost(), connectorServer.getAddress().getPort()).close();
        // Verify that I can connect to the RMI server using the loopback address.
        new Socket(InetAddress.getLoopbackAddress(), connectorServer.getAddress().getPort()).close();
    }

    @Test
    public void testLocalhostRMIBindsToLoopback() throws Exception
    {
        connectorServer = new ConnectorServer(new JMXServiceURL("service:jmx:rmi://localhost/jndi/rmi://localhost:1099/jmxrmi"), objectName);
        connectorServer.start();
        JMXServiceURL address = connectorServer.getAddress();

        InetAddress localHost = InetAddress.getLocalHost();
        if (!localHost.isLoopbackAddress())
        {
            assertThrows(ConnectException.class, () ->
            {
                // Verify that I cannot connect to the RMIRegistry using a non-loopback address.
                new Socket(localHost, address.getPort());
            });
        }

        InetAddress loopback = InetAddress.getLoopbackAddress();
        new Socket(loopback, address.getPort()).close();
    }

    @Test
    public void testRMIServerPort() throws Exception
    {
        ServerSocket server = new ServerSocket(0);
        int port = server.getLocalPort();
        server.close();

        connectorServer = new ConnectorServer(new JMXServiceURL("service:jmx:rmi://localhost:" + port + "/jndi/rmi:///jmxrmi"), objectName);
        connectorServer.start();

        JMXServiceURL address = connectorServer.getAddress();
        assertEquals(port, address.getPort());

        InetAddress loopback = InetAddress.getLoopbackAddress();
        new Socket(loopback, port).close();
    }

    @Test
    public void testRMIServerAndRMIRegistryOnSameHostAndSamePort() throws Exception
    {
        // RMI can multiplex connections on the same address and port for different
        // RMI objects, in this case the RMI registry and the RMI server. In this
        // case, the RMIServerSocketFactory will be invoked only once.
        // The case with different address and same port is already covered by TCP,
        // that can listen to 192.168.0.1:1099 and 127.0.0.1:1099 without problems.

        String host = "localhost";
        ServerSocket serverSocket = new ServerSocket(0);
        int port = serverSocket.getLocalPort();
        serverSocket.close();

        connectorServer = new ConnectorServer(new JMXServiceURL("rmi", host, port, "/jndi/rmi://" + host + ":" + port + "/jmxrmi"), objectName);
        connectorServer.start();

        JMXServiceURL address = connectorServer.getAddress();
        assertEquals(port, address.getPort());
    }

    @Test
    @DisabledOnOs(value = OS.MAC, disabledReason = "Fails on macOS due to SSL socket binding issue when RMI registry and server use same port")
    public void testJMXOverTLS() throws Exception
    {
        SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
        String keyStorePath = MavenTestingUtils.getTestResourcePath("keystore.p12").toString();
        String keyStorePassword = "storepwd";
        sslContextFactory.setKeyStorePath(keyStorePath);
        sslContextFactory.setKeyStorePassword(keyStorePassword);
        sslContextFactory.start();

        // The RMIClientSocketFactory is stored within the RMI stub.
        // When using TLS, the stub is deserialized in a possibly different
        // JVM that does not have access to the server keystore, and there
        // is no way to provide TLS configuration during the deserialization
        // of the stub. Therefore the client must provide system properties
        // to specify the TLS configuration. For this test it needs the
        // trustStore because the server certificate is self-signed.
        // The server needs to contact the RMI registry and therefore also
        // needs these system properties.
        System.setProperty("javax.net.ssl.trustStore", keyStorePath);
        System.setProperty("javax.net.ssl.trustStorePassword", keyStorePassword);
        System.setProperty("jdk.tls.allowCommonNameMatching", "true");
        System.setProperty("java.rmi.server.hostname", "localhost");

        try
        {
            connectorServer = new ConnectorServer(new JMXServiceURL("rmi", "localhost", 1100, "/jndi/rmi://localhost:1100/jmxrmi"), null, objectName, sslContextFactory);
            connectorServer.start();

            // The client needs to talk TLS to the RMI registry to download
            // the RMI server stub, and this is independent from JMX.
            // The RMI server stub then contains the SslRMIClientSocketFactory
            // needed to talk to the RMI server.
            Map<String, Object> clientEnv = new HashMap<>();
            clientEnv.put(ConnectorServer.RMI_REGISTRY_CLIENT_SOCKET_FACTORY_ATTRIBUTE, new SslRMIClientSocketFactory());
            try (JMXConnector client = JMXConnectorFactory.connect(connectorServer.getAddress(), clientEnv))
            {
                client.getMBeanServerConnection().queryNames(null, null);
            }
        }
        finally
        {
            System.clearProperty("javax.net.ssl.trustStore");
            System.clearProperty("javax.net.ssl.trustStorePassword");
            System.clearProperty("jdk.tls.allowCommonNameMatching");
            System.clearProperty("java.rmi.server.hostname");
        }
    }
}
