Skip to content

[#1112] Listen on the configured listen-address in the HTTP connection handler and the JMX RMI connector - #1113

Merged
vharseko merged 3 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-1112-listen-address
Sep 29, 2026
Merged

vharseko merged 3 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-1112-listen-address

Conversation

@vharseko

@vharseko vharseko commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

Fixes #1112.

Problem

The HTTP connection handler bound a single Grizzly listener to 0.0.0.0 whatever listen-address said, and the RMI connector of the JMX connection handler, on rmi-port, did the same: DirectoryRMIServerSocketFactory bound 0.0.0.0 with use-ssl: true, and without SSL there was no server socket factory at all, so RMI used its default one. Both accepted connections on every interface of the host, while the configuration and the monitor showed only the configured address. The RMI registry of the JMX handler, on listen-port, already listened on listen-address.

The LDAP connection handlers and the administration connector are not affected. The SNMP connection handler has a read-only listen-address fixed at 0.0.0.0. With the default listen-address (0.0.0.0), which the packaging does not change, the behaviour stays the same.

Changes

HTTP (HTTPConnectionHandler):

  • one NetworkListener per listen address, on the addresses and the port of initConfig (as before for the port). createHttpServer also sets what getListeners() reports from them, since a configuration change replaces initConfig and a handler that had given up starting starts on the new values; the list is volatile and unmodifiable, since the monitor reads it from other threads;
  • each listener gets its own name (OpenDJ-HTTP <address>): HttpServer keys its listeners by name, so with the old single name only one listener would be kept;
  • each listener gets the transport settings that createHttpServer applied to the single one (configureTransport): each NetworkListener owns its transport, and sharing one would not work, since NetworkListener.start() sets the transport's processor and shutdownNow() shuts the transport down. With several listen addresses, the number of selector threads is multiplied by the number of addresses;
  • when HttpServer.start() fails, startHttpServer shuts the server down before rethrowing. HttpServer.start() stops at the first listener that cannot bind and leaves the listeners started before it bound, while run() only forgets about the server: without this, their ports would stay bound until the JVM stops.

JMX:

  • DirectoryRMIServerSocketFactory takes the listen address, and binds it instead of 0.0.0.0; the address takes part in equals/hashCode, which RMI uses to share endpoints;
  • without SSL, RmiConnector passes OpendsRmiServerSocketFactory(listenAddress), the factory of the registry, instead of null;
  • JmxConnectionHandler.getListeners() reports the listen address instead of HostPort.allAddresses(...) (the same value for 0.0.0.0);
  • before it exports the connector, RmiConnector sets java.rmi.server.hostname to the listen address. The stub that a client gets from the registry advertises that property, or else the address of the local host, whatever address the connector listens on: without it, listen-address: 127.0.0.1 on a host whose name resolves to 127.0.1.1 or to a LAN address sent clients to an address that refuses them. A value that the server did not set is the operator's, and is kept. On the wildcard address, the server's own value becomes the local host when that is not a loopback address, and is cleared otherwise: cleared, the property would leave the JDK advertising the address a previous handler listened on. The property applies to the whole JVM, which the description of listen-address now states;
  • the handler keeps the listen address it was initialized with, since listen-address requires a restart: the registry, a connector restarted by another change (rmi-port, SSL) and getListeners() stay on it until the handler restarts, as the LDAP and HTTP handlers do.

Several listen addresses that overlap, such as 0.0.0.0 together with 127.0.0.1, make the HTTP handler fail to start on Linux, where a listening wildcard socket blocks a specific bind on the same port: the LDAP connection handler already binds each address separately and fails on the same set. {0.0.0.0, ::} fails on any OS, since Java binds both as [::] on a dual-stack host.

Tests

  • HTTPConnectionHandlerTestCase: listensOnlyOnTheConfiguredListenAddress (127.0.0.1 accepts, a non-loopback IPv4 address of the host refuses), listensOnEveryConfiguredListenAddress (127.0.0.1 and the non-loopback address both accept), releasesTheBoundListenAddressesWhenAnotherCannotBeBound (127.0.0.1 and 192.0.2.1, from TEST-NET-1: the port of 127.0.0.1 is released once the handler gives up). The listener of 127.0.0.1 starts before the one of 192.0.2.1: HttpServer starts its listeners in the order of a hash map of their names, and this order was checked with the mutant below.
  • HTTPConnectionHandlerTestCase also: a use-ssl: true row of listensOnEveryConfiguredListenAddress, which completes a TLS handshake on both addresses, and reportsTheListenAddressItStartsWithAfterAChange (a handler on 192.0.2.1 gives up, then listen-address: 127.0.0.1 is applied: it listens there and getListeners() reports it). That case waits for the consecutive-failures alert, then applies the change again until it holds: a change applied while the second start attempt is still running, or just after the alert, is undone when the handler thread disables the handler, a race older than this PR.
  • JmxListenAddressTestCase (new): the listen address is whichever of 127.0.0.1 and the non-loopback address differs from InetAddress.getLocalHost(), and the other one must accept a connection to a wildcard socket before a refusal there counts.
    • listensOnlyOnTheConfiguredListenAddress, with use-ssl false and true: the registry and the RMI connector answer on the listen address only, getListeners() reports it, and a JMX session opens through the registry and calls getMBeanCount();
    • reportsTheListenAddressAfterAChangeOfTheRmiPort; keepsItsListenAddressUntilItRestarts (a change of the address together with rmi-port leaves the registry, the connector, the listeners and the session on the original address); stopsAdvertisingTheLoopbackAddressOnTheWildcardAddress (the stubs of a wildcard handler started after a loopback one do not name 127.0.0.1 or 0.0.0.0; skipped when the local host is a loopback address); keepsTheOperatorsRmiServerHostname (with localhost, a name the server never records itself).
    • When getLocalHost() is exactly 127.0.0.1, the JDK learns its host from the connector's own binding in the registry, and a stub that does not advertise the listen address goes unnoticed: the session check fails on hosts whose name resolves elsewhere, as the Linux CI cells do, and was checked locally with -Djdk.net.hosts.file mapping the host name to 127.0.1.1.
  • TestCaseUtils.getNonLoopbackAddress() / isAcceptingConnections(): the tests are skipped on a host without a non-loopback IPv4 address.

Local runs (macOS, JDK 11):

  • with the fix: 6/6 (HTTPConnectionHandlerTestCase 4, JmxListenAddressTestCase 2);
  • against the main classes of master 79980bd: listensOnlyOnTheConfiguredListenAddress and releasesTheBoundListenAddressesWhenAnotherCannotBeBound fail for HTTP, both JMX cases fail on the RMI connector (the RMI connector answers on <non-loopback address>, which is not the listen address);
  • mutants, each caught by its case: the same listener name for every address, no shutdownNow() on a failed start, the HTTP listener back on DEFAULT_NETWORK_HOST, no server socket factory without SSL, 0.0.0.0 in DirectoryRMIServerSocketFactory, HostPort.allAddresses in getListeners();
  • org/opends/server/protocols/jmx/**, org/opends/server/protocols/http/** and DsconfigOptionsTestCase pass (two classes hit an Address already in use on the test server's own ports at startup, a local port collision with another run, and passed when run again);
  • package with javadoc passes for opendj-server-legacy.

Review round 1 (macOS, JDK 26, on master 5ced345):

  • HTTPConnectionHandlerTestCase 6/6, JmxListenAddressTestCase 5/5, and 5/5 again with the host name mapped to 127.0.1.1;
  • mutants, each caught by its case: no java.rmi.server.hostname (with the host name mapped to 127.0.1.1: Connection refused to host: 127.0.1.1), an operator's value overwritten, the registry left on the old address, HostPort.allAddresses after a change, a plain factory in the SSL arm, SSL on the first HTTP listener only, SSL on no HTTP listener, getListeners() not refreshed when the HTTP server starts;
  • org/opends/server/protocols/jmx/** and org/opends/server/protocols/http/**: 57 tests, no failure; package with javadoc passes.

Review round 2 (macOS, JDK 26, on master 61d6274):

  • HTTPConnectionHandlerTestCase 6/6; JmxListenAddressTestCase 6/6 with the host name mapped to the LAN address;
  • mutants, each caught by its case: the clear-only wildcard branch, the wildcard address advertised, getListenAddress() reading the current configuration, an operator's value overwritten, HostPort.allAddresses after a change, a plain factory in the SSL arm, SSL on the first HTTP listener only, SSL on no HTTP listener, listeners not refreshed in createHttpServer;
  • org/opends/server/protocols/jmx/** and org/opends/server/protocols/http/**: 58 tests, no failure, the wildcard case skipped; package with javadoc passes.

Related

@vharseko vharseko added bug tests Test suites: fixing, enabling, un-disabling security Security fixes / CodeQL code-scanning alerts java Changes to Java sources protocol LDAP protocol extensions, controls and RFC support labels Sep 27, 2026

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: the change binds exactly where the bug is, and cleans up after itself on the failure road.

  • startHttpServer shuts the server down when HttpServer.start() fails (HTTPConnectionHandler.java:742), so the listeners bound before the failing one release their ports; releasesTheBoundListenAddressesWhenAnotherCannotBeBound pins it.
  • DirectoryRMIServerSocketFactory puts the listen address into equals/hashCode, so RMI cannot share one endpoint across two addresses.
  • The new cases run on the Linux CI cells, none skipped: HTTPConnectionHandlerTestCase 4/4 and JmxListenAddressTestCase 2/2 (both use-ssl rows) on ubuntu-latest, JDK 11.

issue (blocking): with a listen-address that is not the host's RMI address, JMX clients can no longer reach the RMI connector.

opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/RmiConnector.java:383, :361, :409

Both connector factories now bind listen-address, but OpendsRMIJRMPServerImpl is still exported through UnicastRemoteObject. Its stub advertises the JDK's RMI local host, which is java.rmi.server.hostname or else InetAddress.getLocalHost(), and nothing in the tree sets that property. Take listen-address: 127.0.0.1 on a Debian/Ubuntu host (hostname → 127.0.1.1), on a host whose name resolves to its LAN IP, or one IP of a multi-homed host. The registry lookup succeeds, then newClient() fails with ConnectException: Connection refused to host: <local host>. At master the connector listened on every address, so the same configuration worked. I measured it with a JDK 26 probe: an ssf bound to 192.168.0.39, with getLocalHost() = 127.0.0.1, gives a stub endpoint of 127.0.0.1 and the call is refused; the wildcard control answers. The default 0.0.0.0 is not affected.

      // The connector's stub advertises the RMI local host, not the address its socket is bound to.
      final InetAddress listenAddress = jmxConnectionHandler.getListenAddress();
      if (!listenAddress.isAnyLocalAddress() && System.getProperty("java.rmi.server.hostname") == null)
      {
        System.setProperty("java.rmi.server.hostname", listenAddress.getHostAddress());
      }
      OpendsRMIJRMPServerImpl opendsRmiConnectorServer =
          new OpendsRMIJRMPServerImpl(jmxConnectionHandler.getRmiPort(),
              rmiClientSockeyFactory, rmiServerSockeyFactory, env);

The JDK reads the property again at each export, so a connector restart picks it up. If the server set the property itself, update it when listen-address changes.

Pin: a JmxListenAddressTestCase case that opens a real JMX session through service:jmx:rmi:///jndi/rmi://<listen address>:<listen-port>/org.opends.server.protocols.jmx.client-unknown and calls getMBeanCount(). Set its listen-address to whichever of 127.0.0.1 and getNonLoopbackAddress() differs from InetAddress.getLocalHost().getHostAddress(), so that it fails at this head on every host. On a Mac whose hostname resolves to 127.0.0.1, a 127.0.0.1 connect passes and hides the bug.


issue (non-blocking): when a connector restart comes with a listen-address change, the connector and the reported listener move to the new address and the RMI registry stays on the old one.

opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/JmxConnectionHandler.java:140, :142, RmiConnector.java:510-527

The registry is rebuilt only when listen-port changed (finalizeConnectionHandler(portChanged), and startCommonRegistry skips while registry != null). Line 140 now reports the new listen-address, and the connector's factory reads it through getListenAddress(). Take one modify that sets ds-cfg-listen-address from 0.0.0.0 to B and changes ds-cfg-rmi-port: the registry stays on 0.0.0.0:listen-port, the connector moves to B, and the monitor publishes B:listen-port. The monitor then under-reports what the registry exposes, where master's 0.0.0.0 matched it. listen-address is flagged server-restart, but nothing on the server side keeps the new value out of currentConfig.

    if (currentConfig.getListenPort() != config.getListenPort()
        || !currentConfig.getListenAddress().equals(config.getListenAddress())) {
      rmiConnectorRestart = true;
      portChanged = true;
    }

suggestion (non-blocking): JmxListenAddressTestCase only checks that a TCP handshake is accepted or refused. It never opens a JMX session.

opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/JmxListenAddressTestCase.java:88-100

isAcceptingConnections is a bare Socket.connect, so nothing beyond the kernel handshake is checked. This is why the blocking issue above stays green. It also means that in the use-ssl row, a plain OpendsRmiServerSocketFactory in the SSL arm of RmiConnector still passes; that swap now compiles, since the local is typed RMIServerSocketFactory. The refusals on getNonLoopbackAddress() have no positive control either: false means refused or dropped alike.

      try (ServerSocket control = new ServerSocket(0))
      {
        assertTrue(isAcceptingConnections(external, control.getLocalPort()),
            external.getHostAddress() + " is not reachable from this host, so a refusal on it proves nothing");
      }

Pin: the JMX session of the blocking issue, with the SSL client environment in the use-ssl row. That session fails if the SSL arm gets a plain factory.


suggestion (non-blocking): no test runs the per-listener SSL setup and transport setup.

opendj-server-legacy/src/main/java/org/opends/server/protocols/http/HTTPConnectionHandler.java:767-780, HTTPConnectionHandlerTestCase.java:189

newHandler() hard-codes ds-cfg-use-ssl: false, and the HTTP handler in config.ldif is disabled, so the setSecure/setSSLEngineConfig block inside the loop is never entered. All of these pass every test: moving the block after the loop (last listener only), guarding it to the first iteration, or deleting it. On an HTTPS handler with two listen addresses, the other address would then serve plaintext. Calling configureTransport on the first listener only also survives, because a default Grizzly transport accepts a TCP connect too.

Pin: a use-ssl: true row of listensOnEveryConfiguredListenAddress (key manager cn=JKS, nickname server-cert, as the JMX test does) that completes startHandshake() with a trust-all SSLSocketFactory on both 127.0.0.1 and the non-loopback address.


suggestion (non-blocking): getListeners() can report other addresses than the ones the HTTP server binds, which contradicts the new comment.

opendj-server-legacy/src/main/java/org/opends/server/protocols/http/HTTPConnectionHandler.java:764, :243, :432

createHttpServer binds initConfig.getListenAddress(). applyConfigurationChange replaces initConfig (:243), while listeners is filled only in initializeConnectionHandler (:432). Suppose run() disabled the instance itself (two failed starts, or configureSSL at init), and the operator then moves listen-address from A to B. The apply re-enables the handler and run() binds B, but getListeners() and the monitor still report A. The port had the same gap on master.

    // Configure one network listener per listen address. HttpServer keys its listeners by name, and each listener
    // owns its transport, so neither can be shared. The addresses are initConfig's: getListeners() reports them
    // until a configuration change replaces initConfig.

Or: fill listeners in createHttpServer from the addresses it binds.


question (non-blocking): should an overlapping listen-address set, such as 0.0.0.0 together with 127.0.0.1, keep starting as it did on master?

opendj-server-legacy/src/main/java/org/opends/server/protocols/http/HTTPConnectionHandler.java:767-771

The handler now binds one listener per address on the same port. With {0.0.0.0, 127.0.0.1} or {0.0.0.0, ::}, the second bind on Linux is expected to fail with EADDRINUSE, since a listening wildcard socket blocks a specific bind even with SO_REUSEADDR. startHttpServer would then fail twice and run() would disable the handler. The LDAP handler already binds each address separately, so failing matches LDAP. If that is intended, a release note is enough. Otherwise, collapse the set to the wildcard when it contains one. Minor either way. Not run: the kernel answer needs a Linux host.


suggestion (non-blocking): the getListeners() change in JmxConnectionHandler.applyConfigurationChange is not pinned.

opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/JmxConnectionHandler.java:140, JmxListenAddressTestCase.java:99

The one getListeners() assert runs right after initializeConnectionHandler (:306). Putting HostPort.allAddresses(config.getListenPort()) back at :140 survives, because the only tests that apply a JMX configuration change (JmxConnectTest.changePort/sslConnect) are @Test(enabled = false).

Pin: in JmxListenAddressTestCase, apply a configuration that differs only in ds-cfg-rmi-port through handler.applyConfigurationChange, then assert getListeners() still containsExactly(new HostPort("127.0.0.1", listenPort)).

@vharseko
vharseko force-pushed the issue-1112-listen-address branch from ecaa491 to 29e308a Compare September 28, 2026 15:18
@vharseko

Copy link
Copy Markdown
Member Author

Round 1 is in 29e308a, on top of master 5ced345 (the branch was rebased first, with no conflict).

Blocking: the stub of the RMI connector. Fixed. Before it exports the connector, RmiConnector.advertiseListenAddress sets java.rmi.server.hostname to the listen address. A value that the server did not set is the operator's (NAT, external name), and is kept. The server remembers its own value, so a later change of listen-address updates it: a == null guard would stop that after the first start. Back on the wildcard address, it clears its value. The JDK then keeps advertising the host it last read, which a connector listening on every address accepts as well. I confirmed that the JDK reads the property again at the next export in the same JVM.

A correction to the pin. It does not fail on every host. When InetAddress.getLocalHost() is exactly 127.0.0.1, the JDK treats its local host as unknown (TCPEndpoint.localHostKnown = false), and learns it from the connector's own JNDI bind, which goes through the listen address. On such a Mac, even a non-loopback listen address passed at the previous head. To reproduce the failing case locally I gave the test JVM -Djdk.net.hosts.file with the host name mapped to 127.0.1.1, as on Debian. Without advertiseListenAddress, the session then fails with Connection refused to host: 127.0.1.1. A standalone probe with the host name mapped to one LAN address and the connector bound to another gives the same refusal on the first address. The CI Linux cells have a known local host, so the pin fails there.

Pin: listensOnlyOnTheConfiguredListenAddress now opens a JMX session through service:jmx:rmi:///jndi/rmi://<listen address>:<listen-port>/… and calls getMBeanCount(), for both use-ssl values. The listen address is whichever of 127.0.0.1 and getNonLoopbackAddress() differs from getLocalHost(). keepsTheOperatorsRmiServerHostname covers the operator's value.

Non-blocking: the registry stays on the old address. Fixed. A change of listen-address restarts the registry as well as the connector. I used a separate addressChanged flag rather than portChanged, so the JMX_port system property is not rewritten when only the address changes. Pin: movesToANewListenAddress moves the handler from one address to the other with nothing else changed. It then checks that the registry and the connector answer only on the new address, that getListeners() reports it, and that a session opens there. With the registry left behind, the connector's JNDI bind to the new address is refused and the change fails.

Non-blocking: TCP handshake only. Fixed as suggested. The session above replaces the bare connect for the positive case. assertListensOnlyOn first checks that the other address accepts a connection to a wildcard ServerSocket(0), and only then counts a refusal there. A plain OpendsRmiServerSocketFactory in the SSL arm now fails the use-ssl row. Two fixture notes for the SSL row. SslRMIClientSocketFactory takes the JVM's default SSL context the first time anything uses it, and that already happens when the connector starts, so the test sets its trust-all context in @BeforeClass. On JDK 26 the factory also checks the host name, and the test certificate has no SAN, so the test's trust manager is an X509ExtendedTrustManager: the JDK adds its own host name check to a plain X509TrustManager.

Non-blocking: per-listener SSL setup. Fixed. listensOnEveryConfiguredListenAddress has a use-ssl: true row (cn=JKS, server-cert), which completes a TLS handshake on 127.0.0.1 and on the non-loopback address. SSL on the first listener only, and SSL on no listener, both fail it now. configureTransport on the first listener only still passes. The settings it applies (buffer sizes, keep-alive, backlog, selector threads) are not observable from a client without timing, so I left that one unpinned.

Non-blocking: getListeners() and initConfig. Fixed the second way you proposed: createHttpServer sets listeners from the addresses and the port it binds. It is now a volatile unmodifiable list, since the monitor reads it from other threads. The comment is corrected. Pin: reportsTheListenAddressItStartsWithAfterAChange starts a handler on 192.0.2.1 and waits for the consecutive-failures alert. It then applies listen-address: 127.0.0.1, waits for the handler to listen there, and checks getListeners(). The wait for the alert is needed. A change applied while the second attempt is still running is undone when that attempt fails and run() disables the handler. That race predates this PR, and I have not changed it.

Question: overlapping listen addresses. I kept it as it is, which is what the LDAP handler does: it binds each address separately (LDAPConnectionHandler, the bind loop over listenAddresses), and fails on the same set. I added a note to the PR description. One more case for that note: {0.0.0.0, ::} collides on any OS, not only Linux, because on a dual-stack host Java binds both as [::].

Non-blocking: getListeners() after a change. Fixed as suggested. reportsTheListenAddressAfterAChangeOfTheRmiPort applies a change of rmi-port only, and checks that getListeners() still reports the listen address, and that the connector answers only there on the new port. HostPort.allAddresses at that line now fails it.

Runs (macOS, JDK 26, TestNG directly on the reactor build):

  • HTTPConnectionHandlerTestCase 6/6, JmxListenAddressTestCase 5/5, and JmxListenAddressTestCase 5/5 again with the host name mapped to 127.0.1.1;
  • new cases against the previous main classes: movesToANewListenAddress and reportsTheListenAddressItStartsWithAfterAChange fail;
  • 8 mutants, each caught by its case: no advertiseListenAddress (with the host name mapped to 127.0.1.1), an operator's value overwritten, the registry left behind, HostPort.allAddresses after a change, a plain factory in the SSL arm, SSL on the first HTTP listener only, SSL on no HTTP listener, listeners not refreshed in createHttpServer;
  • org/opends/server/protocols/jmx/** and org/opends/server/protocols/http/** through Maven: 57 tests, no failure;
  • package with javadoc passes for opendj-server-legacy.

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Round 1's blocking stub problem is fixed where it starts, and the new pins fail on every host.

  • RmiConnector.advertiseListenAddress runs before the export (RmiConnector.java:420). It keeps an operator's java.rmi.server.hostname: the rmiServerHostnameSetByServer guard at :463 is what lets it follow a later listen-address change as well.
  • The JMX fixture listens on whichever of 127.0.0.1 and the non-loopback address differs from getLocalHost() (JmxListenAddressTestCase.java:100-101). assertOpensASession is therefore red without the fix on this Mac as well as on Linux.
  • listeners is now set in the same place the addresses are bound (HTTPConnectionHandler.java:801, volatile at :136).

issue (non-blocking): After a change from 127.0.0.1 to 0.0.0.0, the JMX stubs still advertise 127.0.0.1.

opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/RmiConnector.java:467-472, JmxConnectionHandler.java:117-120, :149-152

addressChanged restarts the registry and the connector, and advertiseListenAddress(0.0.0.0) clears the server's value. After a clear the JDK keeps the last host it read, as the comment at :469-470 says. Here that host is 127.0.0.1. The re-exported connector stub and every per-client RMIConnectionImpl advertise it, so a remote client, the reason to widen the address, gets Connection refused to host: 127.0.0.1 until the server restarts. "Accepts as well" holds only for a local client. The same happens at boot when a specific-address JMX handler starts before a wildcard one. JDK 26 probe: set 10.9.9.9 and export, clear, export on 0.0.0.0: the stub advertises 10.9.9.9. Master needed a restart for this change too (listen-address is <adm:server-restart/>), so this is not a regression. The runtime move added in this round is incomplete in this one direction. Nothing uses 0.0.0.0 in JmxListenAddressTestCase: dropping the clear, or setting the property to 0.0.0.0, survives the class.

// RmiConnector.advertiseListenAddress, the wildcard branch (import java.net.UnknownHostException)
if (listenAddress.isAnyLocalAddress())
{
  // Cleared, the property would leave the JDK advertising the host it last read: the address the connector left.
  final InetAddress localHost = localHostOrNull();
  if (rmiServerHostnameSetByServer != null && localHost != null && !localHost.isLoopbackAddress())
  {
    rmiServerHostnameSetByServer = localHost.getHostAddress();
    System.setProperty(RMI_SERVER_HOSTNAME, rmiServerHostnameSetByServer);
  }
  else
  {
    System.clearProperty(RMI_SERVER_HOSTNAME);
    rmiServerHostnameSetByServer = null;
  }
}

private static InetAddress localHostOrNull()
{
  try
  {
    return InetAddress.getLocalHost();
  }
  catch (UnknownHostException e)
  {
    logger.traceException(e);
    return null;
  }
}

// JmxListenAddressTestCase (import java.rmi.registry.LocateRegistry, java.rmi.registry.Registry, org.testng.SkipException)
/** Back on the wildcard address, the stubs no longer advertise the loopback address the connector left. */
@Test
public void stopsAdvertisingTheLoopbackAddressOnTheWildcardAddress() throws Exception
{
  if (InetAddress.getLocalHost().isLoopbackAddress())
  {
    throw new SkipException("the JDK advertises a loopback address anyway");
  }
  final int[] ports = TestCaseUtils.findFreePorts(2);
  JmxConnectionHandler handler = newHandler(InetAddress.getByName("127.0.0.1"), ports[0], ports[1], false);
  try
  {
    handler.run();
    applyConfigurationChange(handler, newConfig(InetAddress.getByName("0.0.0.0"), ports[0], ports[1], false));

    final Registry registry = LocateRegistry.getRegistry("127.0.0.1", ports[0]);
    for (String name : registry.list())
    {
      assertThat(registry.lookup(name).toString()).doesNotContain("[127.0.0.1:").doesNotContain("[0.0.0.0:");
    }
  }
  finally
  {
    handler.finalizeConnectionHandler(STOP_REASON);
  }
}

Pin: the case above fails on the clear-only branch at this head and on a 0.0.0.0 mutant. It needs the fixture change in the keepsTheOperatorsRmiServerHostname item below, because its name sorts last among the priority-0 cases. Or: leave a change to the wildcard to the server restart that listen-address is already flagged for.


issue (non-blocking): A running JMX handler goes down when only its listen-address changes to an address the host does not have.

opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/JmxConnectionHandler.java:117-120, :149-158

isConfigurationChangeAcceptable returns true, and isConfigurationAcceptable checks only ports, on the wildcard. A typo such as 10.9.9.9 is therefore accepted and written to config.ldif. finalizeConnectionHandler(true) then closes the registry socket, and createRegistry fails on 10.9.9.9 ("Can't assign requested address"). JMX stays down until the next change or restart, and the only sign is an error result. On master the same change restarted nothing, so the handler served until the restart.

// JmxConnectionHandler.isConfigurationAcceptable, before the port checks
// (import java.net.NetworkInterface, java.net.SocketException)
final InetAddress address = config.getListenAddress();
try
{
  if (!address.isAnyLocalAddress() && NetworkInterface.getByInetAddress(address) == null)
  {
    unacceptableReasons.add(LocalizableMessage.raw("No interface of this host has the address " + address.getHostAddress()));
    return false;
  }
}
catch (SocketException e)
{
  logger.traceException(e);
}

Or: when initialize() fails, restart on the previous configuration.


issue (non-blocking): reportsTheListenAddressItStartsWithAfterAChange can still lose its change to the handler thread.

opendj-server-legacy/src/test/java/org/opends/server/protocols/http/HTTPConnectionHandlerTestCase.java:195-205, HTTPConnectionHandler.java:734-735, :262

The handler thread sends the consecutive-failures alert at :734 and sets enabled = false only at :735, after the other alert handlers and the log line. DummyAlertHandler's count is visible before that. If the thread is preempted in that window, the test's apply sets enabled = true (:262) and :735 then overwrites it. The handler idles, and the second wait fails with "the new listen address is not listened on" on correct code. This is rare, but nothing orders the two writes: there is no shared lock and enabled is not volatile.

final InetAddress loopback = loopback();
timer.repeatUntilSuccess(new TestTimer.CallableVoid()
    {
      @Override
      public void call() throws Exception
      {
        // Applied again until it holds: the handler thread clears enabled just after it sends the alert.
        handler.applyConfigurationChange(newConfig(false, listenPort, "127.0.0.1"));
        assertTrue(isAcceptingConnections(loopback, listenPort), "the new listen address is not listened on");
      }
    });

issue (non-blocking): keepsTheOperatorsRmiServerHostname passes only because of the order in which the priority-0 cases run.

opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/JmxListenAddressTestCase.java:252-266, RmiConnector.java:182, :463

The operator's value is otherAddress (:256), and the static rmiServerHostnameSetByServer is never reset between cases. movesToANewListenAddress leaves that field at otherAddress. If it were the last priority-0 case to run (after a rename, a new case or another TestNG order), the guard at :463 would take the operator's value for the server's own. :476-477 would then overwrite it, and :264 would fail with no product change. Today it passes because reportsTheListenAddressAfterAChangeOfTheRmiPort sorts later and records listenAddress again.

final String operatorsHostname = "203.0.113.7"; // TEST-NET-3: a value no case makes the server record

suggestion (non-blocking): State in the docs that a specific JMX listen address sets java.rmi.server.hostname for the whole JVM.

opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/RmiConnector.java:182, :460-479

Every later RMI export in the process reads the property again. That includes the platform JMX agent's per-client RMIConnectionImpl (-Dcom.sun.management.jmxremote.port) and the exports of an embedding application. With the JMX handler on 127.0.0.1 and no operator value, remote jconsole clients of the platform agent get stubs for 127.0.0.1. A JDK 26 probe confirmed this, and disabling the handler does not undo it. With two JMX handlers on different specific addresses, the handler that started last wins, and every later client of the other one is refused. The JDK has no API to set the host for one export only, so this is the cost of the approach. It belongs in the listen-address description of the JMX handler or in the release notes, together with the fact that -Djava.rmi.server.hostname overrides it.

…n the HTTP connection handler and the JMX RMI connector

The HTTP connection handler bound a single Grizzly listener to the wildcard
address whatever listen-address said, and the RMI connector of the JMX
connection handler, on rmi-port, did the same (DirectoryRMIServerSocketFactory
bound 0.0.0.0 with SSL, the default RMI factory without it). Both accepted
connections on every interface of the host while the configuration and the
monitor showed only the configured address.

HTTP: one NetworkListener per listen address, on the addresses and the port
that getListeners() reports. Each listener gets its own name, since HttpServer
keys its listeners by name, and its own transport settings, since each listener
owns its transport. When HttpServer.start() fails on one address, the listeners
already started are shut down: the caller only forgets about the server, so
their ports would stay bound for good.

JMX: the RMI connector listens on listen-address, as the RMI registry already
does, with and without SSL, and getListeners() reports that address instead of
0.0.0.0.
…of the JMX RMI connector, and move the RMI registry with it

The stub that a JMX client gets from the RMI registry advertises java.rmi.server.hostname, or else the
address of the local host, whatever address the connector listens on. With the connector bound to its
listen address, a client was sent to an address that refuses it whenever the two differ: listen-address
127.0.0.1 on a host whose name resolves to 127.0.1.1 or to a LAN address, or one address of a
multi-homed host. RmiConnector now sets the property to the listen address before it exports the
connector, keeps a value that the operator set, and clears its own value when the connector goes back
to the wildcard address.

A change of listen-address restarts the RMI registry as well as the connector, so the registry, the
connector and getListeners() stay on the same address.

The HTTP connection handler reports the addresses and the port its server starts with: a configuration
change replaces initConfig, and a handler that had given up starting starts on the new values.

Tests: JmxListenAddressTestCase opens a JMX session through the registry for both use-ssl values, on the
address that is not the local host's, checks that the other address is reachable before it counts a
refusal there, and covers an rmi-port change, a listen-address change and an operator's
java.rmi.server.hostname. HTTPConnectionHandlerTestCase completes a TLS handshake on both listen
addresses of an HTTPS handler, and checks the reported address after a change that lets the handler
start.
…dler restarts, and advertise the local host on the wildcard address

listen-address of the JMX connection handler requires a server restart. The previous round restarted the
RMI registry and the connector on a change of it instead, which moved a running handler to an address the
host may not have (a typo left JMX down), and to the wildcard address with stubs that still advertised the
loopback address it left. The handler now keeps the address it was initialized with: the registry, a
connector restarted by another change and getListeners() stay on it until the handler restarts.

A handler restarted on the wildcard address, after one on a specific address in the same JVM, sets
java.rmi.server.hostname to the local host when that is not a loopback address, instead of clearing it:
cleared, the property leaves the JDK advertising the host it last read.

The description of listen-address states that a specific address sets java.rmi.server.hostname for the
whole JVM unless it is already set.

Tests: keepsItsListenAddressUntilItRestarts replaces movesToANewListenAddress;
stopsAdvertisingTheLoopbackAddressOnTheWildcardAddress looks up the stubs of a wildcard handler started
after a loopback one; the operator's value is "localhost", which the server never records itself; the HTTP
case applies its change again until it holds, since the handler thread disables the handler just after it
sends the alert.
@vharseko
vharseko force-pushed the issue-1112-listen-address branch from 29e308a to 0585602 Compare September 28, 2026 16:52
@vharseko

Copy link
Copy Markdown
Member Author

Round 2 is in 0585602, on top of master 61d6274 (the branch was rebased first, with no conflict).

The first two items: the wildcard address after 127.0.0.1, and a listen address the host does not have. I took the other road you named: a change of listen-address now waits for the handler to restart, as <adm:server-restart/> says. This withdraws what I did for your round-1 item on the registry. The handler keeps the address it was initialized with (JmxConnectionHandler.listenAddress, returned by getListenAddress()). The registry, a connector restarted by another change, and getListeners() all stay on it until the handler restarts. That is also what the LDAP and HTTP handlers do. With this, a typo such as 10.9.9.9 no longer takes a running handler down. It takes effect, and fails, only when the handler restarts, the same as on master. The registry and the connector can no longer end up on different addresses.

The wildcard branch still needed your fix. Disabling and enabling the handler creates a new instance with the current configuration, so 127.0.0.1 followed by 0.0.0.0 still happens without a server restart, and so does a boot with a specific-address handler before a wildcard one. advertiseListenAddress now sets the property to the local host when the server had set it and the local host is not a loopback address, and clears it otherwise, as in your snippet.

Pins:

  • keepsItsListenAddressUntilItRestarts replaces movesToANewListenAddress. It applies a change of the address together with rmi-port, so that the connector restarts. It then checks that the registry and the connector answer only on the original address, that getListeners() reports that address, and that a session opens there. With getListenAddress() reading the current configuration again, it fails.
  • stopsAdvertisingTheLoopbackAddressOnTheWildcardAddress is your case. It runs a handler on 127.0.0.1, stops it, then runs one on 0.0.0.0 rather than applying a change. It fails with the clear-only branch and with the wildcard address advertised. It is skipped when the local host is a loopback address. I ran it locally with the host name mapped to the LAN address.

Third item: the HTTP race. Fixed as suggested. The change is applied again inside the wait until it holds. The wait for the alert stays, so the first apply does not land before the handler gives up.

Fourth item: the operator's value. Fixed, with "localhost" rather than 203.0.113.7. The server records only addresses, so it never takes this name for its own value. The registry also calls the stubs that are bound in it, so the advertised host has to answer at once. With an unreachable one, the connector's own bind into the registry waits for a connect timeout. I saw it block for more than 200 seconds on a mutant that advertised 127.0.1.1 on macOS.

Suggestion: document the global property. Done in the description of the JMX handler's listen-address (JMXConnectionHandlerConfiguration.xml): a specific address sets java.rmi.server.hostname unless it is already set, -Djava.rmi.server.hostname overrides it, it applies to every RMI export of the JVM including the platform JMX agent, and with several JMX handlers on different specific addresses the last one started sets it.

Runs (macOS, JDK 26):

  • HTTPConnectionHandlerTestCase 6/6; JmxListenAddressTestCase 6/6 with the host name mapped to the LAN address, and 5/6 plus the wildcard case skipped on the plain Mac, whose local host is 127.0.0.1;
  • mutants, each caught by its case: the clear-only wildcard branch, the wildcard address advertised, getListenAddress() reading the current configuration, an operator's value overwritten, HostPort.allAddresses after a change, a plain factory in the SSL arm, SSL on the first HTTP listener only, SSL on no HTTP listener, listeners not refreshed in createHttpServer;
  • org/opends/server/protocols/jmx/** and org/opends/server/protocols/http/** through Maven: 58 tests, no failure, the wildcard case skipped; package with javadoc passes.

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Round 2's two JMX items are closed at the source, not worked around.

  • A change of listen-address now waits for the restart that <adm:server-restart/> announces. listenAddress is set only at initialization (JmxConnectionHandler.java:314), and getListenAddress(), the listeners and a connector restarted by another change all read it. A typo no longer takes JMX down, and the registry and the connector can no longer end up on different addresses.
  • The wildcard arm advertises the local host, which is the JDK's own default, instead of clearing back to a stale loopback host. It keeps the clear when the local host is a loopback address (RmiConnector.java:468-485).
  • The HTTP case applies its change again inside the wait (HTTPConnectionHandlerTestCase.java:212), so the race with the handler thread cannot undo it.

@vharseko
vharseko merged commit 9af5bd3 into OpenIdentityPlatform:master Sep 29, 2026
24 checks passed
@vharseko
vharseko deleted the issue-1112-listen-address branch September 29, 2026 06:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug docs java Changes to Java sources protocol LDAP protocol extensions, controls and RFC support security Security fixes / CodeQL code-scanning alerts tests Test suites: fixing, enabling, un-disabling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The HTTP connection handler and the JMX RMI connector ignore listen-address and listen on every interface

2 participants