Skip to content

[#1109] Keep a connection handler listening when a change to it is rejected - #1110

Merged
vharseko merged 2 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-1109-rejected-change-keeps-handler
Sep 28, 2026
Merged

vharseko merged 2 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-1109-rejected-change-keeps-handler

Conversation

@vharseko

@vharseko vharseko commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Fixes #1109

Problem

When a change to a connection handler is checked, the check builds an SSL context for the proposed configuration through the same createSSLContext as the start of the handler. That method disables the handler (enabled = false) when its key store holds no usable key or lacks the configured alias. A key store that cannot be loaded (replaced by one with another password, truncated, missing) takes those branches, because containsAtLeastOneKey() and containsKeyWithAlias() return false on any load failure. getKeyManagers() then throws, so the change is rejected and applyConfigurationChange never runs to set enabled back. The handler thread stops the listener, and the configuration still says enabled: true. The handler stays down after the key store is fixed, until a later change is accepted or the server restarts.

The same happens to:

  • the LDAPS connection handler (LDAPConnectionHandler2);
  • the administration connector, which is an LDAPConnectionHandler2 checked through AdministrationConnector.isConfigurationChangeAcceptable. Once port 4444 is down, dsconfig cannot reach the server any more; only ldapmodify on cn=Administration Connector,cn=config over plain LDAP, or a restart, brings it back;
  • the HTTPS connection handler (HTTPConnectionHandler), whose createSSLContext sets enabled = false directly;
  • the legacy LDAPConnectionHandler, which has the same code as LDAPConnectionHandler2.

All four were reproduced in Docker on openidentityplatform/opendj:latest (5.1.2); the steps are in #1109.

Change

createSSLContext in LDAPConnectionHandler2, LDAPConnectionHandler and HTTPConnectionHandler (and HTTPConnectionHandler.createSSLEngineConfigurator) takes a forUse flag:

  • configureSSL, called at the start of the handler and by applyConfigurationChange, passes true: an SSL handler without a usable key is still disabled at its start, and by an applied change to an LDAP handler, as before. HTTPConnectionHandler.applyConfigurationChange sets enabled from the configuration after configureSSL, so an applied change never disabled the HTTP handler; that is left as it was and filed as An applied change that leaves the HTTP connection handler without a usable key logs "Disabling" but the handler keeps serving until the restart #1111;
  • isConfigurationAcceptable, reached from isConfigurationChangeAcceptable, ConnectionHandlerConfigManager and AdministrationConnector, passes false: the check leaves the running handler as it was.

A key store that cannot be loaded still makes getKeyManagers() throw, so the change is still rejected with the reason it gave before. The error messages about the key store are still logged during the check; only the "Disabling …" warning and the change to enabled are skipped. In HTTPConnectionHandler the three identical disable blocks are folded into disableAndWarn(forUse).

Tests

New RejectedSSLConfigurationChangeTestCase, 13 cases, with its own file-based key manager provider on a temporary copy of the test key store:

  • rejectedChangeKeepsTheHandlerListening, for LDAPConnectionHandler2, the legacy LDAPConnectionHandler and HTTPConnectionHandler, each with and without ssl-cert-nickname (so both the no-key branch and the alias branch are covered): start an SSL handler, check it serves TLS, overwrite the key store file with garbage, check that a change (max-request-size) is rejected with ERR_CONNHANDLER_SSL_CANNOT_INITIALIZE, then check the handler keeps serving TLS for 3 seconds (its thread re-reads enabled every second);
  • handlerWithoutItsCertificateDoesNotListen, for the same three handlers: a handler whose key store lacks the configured ssl-cert-nickname is still disabled at its start. This pins the true passed by configureSSL;
  • checkWithoutKeyManagerProviderKeepsTheHandlerListening, for the same three handlers: the test's key manager provider is deregistered from DirectoryServer around a check (the state of an enabled provider whose key store could not be loaded at the start of the server: its entry exists, it is not registered), and the running handler keeps serving TLS for 3 seconds;
  • appliedChangeWithoutItsCertificateStopsListening, for LDAPConnectionHandler2: a change to ssl-cert-nickname: no-such-cert is accepted by the check and, once applied, stops the listener. The legacy handler keeps accepting TCP connections while disabled, and the HTTP handler is not disabled by an applied change (An applied change that leaves the HTTP connection handler without a usable key logs "Disabling" but the handler keeps serving until the restart #1111), so neither can be pinned this way.

tearDown drops the referential integrity reference that addLDAPChangeListener / addHTTPChangeListener registers from the handler to the key manager provider (it outlives the handler), so the provider entry can be deleted.

Results:

  • on master, the 6 rejectedChangeKeepsTheHandlerListening cases fail (Connection refused for LDAP2 and HTTP, Read timed out for the legacy handler, which accepts one more connection before it closes); with the change, 13/13 pass;
  • 9 mutants, each reverting one forUse argument (the check in each handler, only the alias branch of LDAP2 and HTTP, only the no-key branch of LDAP2, the start path in each handler), are each caught by the expected cases. Seven more are each caught too: the base's true in the branch without a registered key manager provider, in each handler (by checkWithoutKeyManagerProviderKeepsTheHandlerListening for its kind); enabled = config.isEnabled() moved below configureSSL in LDAPConnectionHandler2.applyConfigurationChange (by appliedChangeWithoutItsCertificateStopsListening); and the port guard of the check forced to true in LDAPConnectionHandler2 and LDAPConnectionHandler (by the reason check of rejectedChangeKeepsTheHandlerListening). The HTTP variant of that last mutant is not observable on macOS: the HTTP handler binds Grizzly to the wildcard address rather than to listen-address, so the guard's probe on 127.0.0.1 binds next to it (The HTTP connection handler and the JMX RMI connector ignore listen-address and listen on every interface #1112);
  • related classes pass: TestLDAPConnectionHandler, HTTPConnectionHandlerTestCase, StartTLSExtendedOperationTestCase, ExternalSASLMechanismHandlerTestCase, the three certificate mapper test cases, RejectUnauthReqTests, LDAPAuthenticationHandlerTestCase, LDAPConnectionTestCase, DsconfigOptionsTestCase.

Related

#1095 / PR #1101 (the server loads a changed key store file without a restart): with #1101 a key store renewed together with its PIN file loads, but one that cannot be loaded at all still reaches the branches changed here. #1087 / PR #1100 holds back a new key store password until the next start of the Docker container partly because of this issue.

#1111: an applied change that leaves the HTTP connection handler without a usable key logs "Disabling", but the handler keeps serving until the restart. Found in the review of this PR; it predates it.

@vharseko vharseko added bug tests Test suites: fixing, enabling, un-disabling java Changes to Java sources labels Sep 25, 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 check of a proposed configuration no longer touches the running handler, and the new test proves it both ways.

  • isConfigurationAcceptable now calls createSSLContext(config, false) (LDAPConnectionHandler2.java:582, and the same in LDAPConnectionHandler and HTTPConnectionHandler), while configureSSL keeps passing true for the start and the apply.
  • RejectedSSLConfigurationChangeTestCase against the base's three handler files: 6 of 9 fail, all six rejectedChangeKeepsTheHandlerListening cases, each on "the handler does not serve TLS". The three handlerWithoutItsCertificateDoesNotListen cases pass. At the head it is 9/9 green in CI (build-maven (ubuntu-latest, 11)).
  • The three identical disable blocks in HTTPConnectionHandler.createSSLContext are folded into disableAndWarn(forUse).

issue (non-blocking): For HTTPConnectionHandler, the new forUse javadoc says an applied change disables a handler without a usable key, but an HTTP apply never leaves the handler disabled.

opendj-server-legacy/src/main/java/org/opends/server/protocols/http/HTTPConnectionHandler.java:825-829, :216, :245

applyConfigurationChange calls configureSSL(config) at :216, and disableAndWarn(true) there logs "Disabling …" and sets enabled = false. Then :245 runs this.enabled = this.currentConfig.isEnabled();, which undoes it. So an accepted HTTPS change that names an ssl-cert-nickname missing from the key store logs the warning while the handler keeps listening. It keeps its old certificate, because every SSL property is in anyChangeRequiresRestart (:250-266). This behaviour predates the PR. What is new is the documented contract, which is false for HTTP. The commit body makes the same claim ("Only the start of the handler and an applied change disable it"). The LDAP handlers set enabled before configureSSL (LDAPConnectionHandler2.java:293/:299, LDAPConnectionHandler.java:299/:306), so the javadoc holds for them.

   * @param forUse
   *          {@code true} when the handler is going to use the configurator: at its start a handler
   *          without a usable key is disabled ({@link #applyConfigurationChange} sets {@code enabled}
   *          from the configuration afterwards); {@code false} when the configurator only checks a
   *          proposed configuration, which must leave the running handler as it is

Or: move this.enabled = … above configureSSL(config) so HTTP matches the LDAP handlers. That changes behaviour and falls outside this PR.


suggestion (non-blocking): No case drives the applied-change road that the new javadocs name ("at its start or when a change is applied").

opendj-server-legacy/src/test/java/org/opends/server/protocols/RejectedSSLConfigurationChangeTestCase.java:201, opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPConnectionHandler2.java:293-299

The start (handlerWithoutItsCertificateDoesNotListen) and the check (rejectedChangeKeepsTheHandlerListening) are pinned, but no test calls applyConfigurationChange on a running SSL handler. So moving enabled = config.isEnabled() below configureSSL(config) in LDAPConnectionHandler2.applyConfigurationChange, as HTTP has it, keeps all 9 cases green. The check accepts a missing nickname, because wrapping an empty alias set does not throw, so dsconfig can reach this road. The gap predates the PR.

  /** An applied change that leaves the handler without its certificate still disables it. */
  @SuppressWarnings("unchecked")
  @Test
  public void appliedChangeWithoutItsCertificateStopsListening() throws Exception
  {
    final int port = TestCaseUtils.findFreePort();
    final ConnectionHandler<?> handler = start(Kind.LDAP2, configuration(Kind.LDAP2, port, null, "5 megabytes", true));
    try
    {
      assertServesTLS(port);
      ((ConfigurationChangeListener<LDAPConnectionHandlerCfg>) handler).applyConfigurationChange(
          (LDAPConnectionHandlerCfg) configuration(Kind.LDAP2, port, "no-such-cert", "5 megabytes", true));

      final long deadline = System.currentTimeMillis() + KEEPS_SERVING_MS;
      while (true)
      {
        try (Socket socket = new Socket("127.0.0.1", port))
        {
          assertTrue(System.currentTimeMillis() < deadline, "the handler still listens after the applied change");
        }
        catch (ConnectException expected)
        {
          break;
        }
        Thread.sleep(250);
      }
    }
    finally
    {
      ((ServerShutdownListener) handler).processServerShutdown(STOP_REASON);
      handler.finalizeConnectionHandler(STOP_REASON);
      handler.join(10000);
      assertFalse(handler.isAlive(), "the connection handler thread is still running");
    }
  }

Pin: under the mutant the listener stays up and the connect succeeds. At the head, run() stops the listener. Not run. This pin checks the connect, which is why it covers only LDAP2. The legacy handler can't be pinned the same way: when it is disabled while running it still accepts the TCP connect and only the handshake times out (measured against the base), and after this change its SSL context holds no key, so a failed handshake shows nothing.


suggestion (non-blocking): No test sets forUse in the null-key-manager-provider branch.

opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPConnectionHandler2.java:966-969, opendj-server-legacy/src/main/java/org/opends/server/protocols/http/HTTPConnectionHandler.java:910-914, LDAPConnectionHandler.java:1328

configuration() always names the provider that setUp registers, so keyManagerProvider == null is never reached. Restoring true in that branch, which is the base's code there, leaves the suite green. Production can reach the branch. An enabled provider whose key store failed to load at startup is never registered (KeyManagerProviderConfigManager skips it). The aggregation check reads the config entry, not the registry. So a modify that names such a provider together with an unsupported ssl-protocol passes through this branch during the check and is then refused. Under the mutant, that refused change stops the listener, which is #1109 again.

  // configuration(...) gains a trailing DN keyManagerDN parameter used in place of KEY_MANAGER_DN;
  // the existing five-argument form delegates with KEY_MANAGER_DN.

  /** A check that finds no registered key manager provider leaves the running handler as it is. */
  @Test(dataProvider = "kinds")
  public void checkWithoutKeyManagerProviderKeepsTheHandlerListening(Kind kind) throws Exception
  {
    final int port = TestCaseUtils.findFreePort();
    final ConnectionHandler<?> handler = start(kind, configuration(kind, port, null, "5 megabytes", true));
    try
    {
      handler.isConfigurationAcceptable(configuration(kind, port, null, "6 megabytes", true,
          DN.valueOf("cn=No Such Keys,cn=Key Manager Providers,cn=config")), new ArrayList<LocalizableMessage>());
      final long deadline = System.currentTimeMillis() + KEEPS_SERVING_MS;
      do
      {
        assertServesTLS(port);
        Thread.sleep(250);
      }
      while (System.currentTimeMillis() < deadline);
    }
    finally
    {
      ((ServerShutdownListener) handler).processServerShutdown(STOP_REASON);
      handler.finalizeConnectionHandler(STOP_REASON);
      handler.join(10000);
      assertFalse(handler.isAlive(), "the connection handler thread is still running");
    }
  }

Pin: the key store is left intact, so the running context keeps its key, and under the mutant assertServesTLS fails within the 3 s window for all three kinds. Not run. I haven't checked whether InitializationUtils.getConfiguration decodes a reference to a missing entry.


suggestion (non-blocking): rejectedChangeKeepsTheHandlerListening accepts any rejection reason, not specifically the SSL one.

opendj-server-legacy/src/test/java/org/opends/server/protocols/RejectedSSLConfigurationChangeTestCase.java:174

The PR says the change "is still rejected with the reason it gave before", but the case only asserts !reasons.isEmpty(). A refusal that never reaches createSSLContext(config, false) also passes. One example: force the port-check guard (currentConfig == null || …) to true. The check then refuses with address-in-use on the port the running handler holds, the handler keeps serving, and the case stays green. Today every exception on the check road of all three handlers is wrapped into ERR_CONNHANDLER_SSL_CANNOT_INITIALIZE, so the reason holds, but nothing pins it.

      assertFalse(reasons.isEmpty(), "the change was rejected without a reason");
      assertEquals(reasons.get(0).ordinal(), ERR_CONNHANDLER_SSL_CANNOT_INITIALIZE.ordinal(), String.valueOf(reasons));

Pin: with the static import from org.opends.messages.ProtocolMessages, the port-guard mutant makes the case fail. Not run.

…a change to it is rejected

The check of a proposed configuration built its SSL context through the
same code as the start of the handler, and that code disables the handler
when its key store holds no usable key. A key store that cannot be loaded
takes that branch, so the change was rejected and the running handler
stopped listening, until a later change was accepted or the server
restarted. The administration connector, an LDAPConnectionHandler2, and
the HTTP connection handler behaved the same way; with the administration
connector down, dsconfig could not reach the server any more.

createSSLContext in LDAPConnectionHandler2, LDAPConnectionHandler and
HTTPConnectionHandler now takes whether the handler is going to use the
context. Only the start of the handler and an applied change disable it.
The check still rejects the change with the reason it gave before.

Fixes OpenIdentityPlatform#1109
…out a key manager provider

The forUse javadoc of HTTPConnectionHandler no longer says that an applied
change disables the handler: applyConfigurationChange sets enabled from the
configuration after configureSSL, so only the start does. The previous
commit says the same of all three handlers; that holds for the two LDAP
handlers only. The HTTP behaviour predates this change and is OpenIdentityPlatform#1111.

RejectedSSLConfigurationChangeTestCase gains:
- appliedChangeWithoutItsCertificateStopsListening: an applied change that
  takes the certificate of LDAPConnectionHandler2 away is accepted by the
  check and stops the listener;
- checkWithoutKeyManagerProviderKeepsTheHandlerListening, for the three
  handlers: a check that finds no key manager provider registered under the
  configured DN leaves the running handler serving TLS;
- rejectedChangeKeepsTheHandlerListening checks that the change is rejected
  with ERR_CONNHANDLER_SSL_CANNOT_INITIALIZE, not with any reason.
@vharseko
vharseko force-pushed the issue-1109-rejected-change-keeps-handler branch from c35c6c5 to b4b0cad Compare September 27, 2026 08:48
@vharseko

Copy link
Copy Markdown
Member Author

Thanks. All four points are taken in b4b0cad. The branch is also rebased onto master 79980bd.

issue: HTTP javadoc. Confirmed. :216 disables and :245 enables again, so the HTTP handler keeps listening while the log says "Disabling". The forUse javadoc of createSSLEngineConfigurator now uses your wording: only the start disables it, and applyConfigurationChange sets enabled from the configuration afterwards. The first commit's body and the Change section of the description said the same about all three handlers. The new commit's body corrects that, and the description is updated. I left the order in applyConfigurationChange as it is and filed #1111. A throwaway test there shows the check accepting ssl-cert-nickname: no-such-cert and the apply logging "Disabling", while enabled stays true and the handler serves TLS 5 s later. A new handler started with the applied configuration then does not listen.

suggestion: apply road. Added appliedChangeWithoutItsCertificateStopsListening, LDAP2 only, for the reasons you gave. It also asserts that the check accepts the change, which is how dsconfig gets to the apply. The deadline for the listener to go away is 10 s, not the 3 s keep-serving window, because the thread re-reads enabled only once a second. Mutant: enabled = config.isEnabled() moved below configureSSL in LDAPConnectionHandler2.applyConfigurationChange. Only this case fails ("the handler still listens … after a change took its certificate away").

suggestion: null key manager provider. Added checkWithoutKeyManagerProviderKeepsTheHandlerListening for the three handlers. The fixture differs from your sketch: rather than naming a DN with no entry, the case deregisters the test's provider from DirectoryServer around the check and registers it again in a finally. That is the production state you describe (entry present, provider not registered) and it doesn't depend on how a reference to a missing entry decodes. The case doesn't assert the check's verdict, only that the running handler keeps serving TLS for 3 s. Mutants: the base's true in the null branch of each handler. Each fails only this case for its own kind: Connection refused for LDAP2 and HTTP, Read timed out for the legacy handler.

suggestion: rejection reason. rejectedChangeKeepsTheHandlerListening now asserts reasons.get(0).ordinal() == ERR_CONNHANDLER_SSL_CANNOT_INITIALIZE.ordinal(). With the port guard forced to true, the LDAP2 and legacy cases fail with "unable to bind to 127.0.0.1:… Address already in use", and so does the new apply case, whose check is then refused. The HTTP variant of that mutant survives locally on macOS. The reason is not the test: createHttpServer binds Grizzly to NetworkListener.DEFAULT_NETWORK_HOST rather than to listen-address, so the guard's SO_REUSEADDR probe on 127.0.0.1 binds next to the wildcard listener, the guard refuses nothing, and the check goes on to the SSL reason. I haven't checked it on Linux. The HTTP handler ignoring listen-address is filed as #1112.

Results: the class runs 13/13 at the head through the reactor (-Pprecommit verify -Dit.test=RejectedSSLConfigurationChangeTestCase, with attach-javadocs on the module), and 13/13 again with direct TestNG. Each of the seven mutants above was run as a separate class run.

@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 second commit closes the test gaps left after round 1 and makes the HTTP javadoc match what the code does.

  • checkWithoutKeyManagerProviderKeepsTheHandlerListening reaches the check-side null-provider branch of all three handlers by deregistering KEY_MANAGER_DN after the start.
  • rejectedChangeKeepsTheHandlerListening now requires ERR_CONNHANDLER_SSL_CANNOT_INITIALIZE; before, any reason passed.
  • The forUse javadoc of HTTPConnectionHandler.createSSLEngineConfigurator now names only the start and points at applyConfigurationChange. The HTTP apply behaviour is split out as #1111.

@vharseko
vharseko merged commit 0b846fb into OpenIdentityPlatform:master Sep 28, 2026
24 checks passed
@vharseko
vharseko deleted the issue-1109-rejected-change-keeps-handler branch September 28, 2026 12:54
vharseko added a commit to vharseko/OpenDJ that referenced this pull request Sep 28, 2026
…ey the handler can present, and leave a running HTTP handler as it is when one is applied

The SSL settings of the HTTP connection handler take effect only when it
restarts: Grizzly takes the SSL engine configuration when the handler
starts its HTTP server, and every SSL property is in
anyChangeRequiresRestart. A change that left the handler without a key
it can present (no key manager provider registered under the configured
DN, a key store without a key, or none of the ssl-cert-nickname aliases
in it) was accepted by the check, because wrapping an empty alias set
does not throw. The apply then logged "Disabling ...", set enabled to
false, and set it back from the configuration a few lines later, so the
handler kept serving with its old certificate and was down only after
the restart. A handler that its start had disabled for want of a key
was even started by such a change, with an SSL context without a key.

createSSLContext no longer touches enabled. It collects why the
configuration leaves the handler without a key, and each caller decides
what that means:

- the check refuses the configuration with these reasons, for a change
  to a running handler and for a handler being added or enabled, which
  is checked on a new instance. A key store that cannot be loaded is
  still refused with ERR_CONNHANDLER_SSL_CANNOT_INITIALIZE, as before;
- the start logs the reasons and "Disabling ...", and disables the
  handler, as before;
- the apply, reached when the key store changed after the check, adds
  the reasons and the new WARN_CONNHANDLER_NO_KEY_UNTIL_RESTART to the
  result, asks for administrative action, and logs no "Disabling". A
  running handler keeps serving with the SSL settings it started with;
  a handler that is down stays down. A change that gives a handler
  disabled at its start a key still starts it, as before.

The reasons name the configuration by config.name() rather than by
friendlyName, which is not set on the new instance the check of an
added or enabled handler runs on. A missing alias is reported by its
own name rather than by the whole alias set, and while another
configured alias is in the key store it is only logged, as before.

The forUse flag and disableAndWarn that OpenIdentityPlatform#1110 added to the HTTP handler
are replaced by the collected reasons. The LDAP handlers are left as
they are: they build an SSL engine for each new connection, so their SSL
changes take effect at once, and an applied change without a key
disables them at once.

RejectedSSLConfigurationChangeTestCase gains 11 HTTP cases: the check
refusing each of the three kinds of missing key on a running handler
and on a new instance, with the reason naming the handler; the check
accepting a configuration with one of its aliases in the key store; the
apply of a change without a key, by a changed nickname and by a key
store emptied under an unchanged configuration; and a handler disabled
at its start staying down on a change still without a key and starting
on one that gives it a key.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug java Changes to Java sources tests Test suites: fixing, enabling, un-disabling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A rejected change to the LDAPS connection handler stops the handler listening until a later change is accepted or the server restarts

2 participants