Skip to content

Commit 78f806c

Browse files
IGNITE-28867 Implement TLS certificate hot redeployment via control.sh --ssl reload
Reload TLS certificates on running nodes without a restart. The new `control.sh --ssl reload` command re-reads the configured key and trust stores from disk and replaces the active SSL context for the communication, discovery, thin client (client connector) and binary (TCP) REST transports. New connections use the updated certificates while established sessions are not interrupted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent be8f8e2 commit 78f806c

21 files changed

Lines changed: 843 additions & 16 deletions

File tree

docs/_docs/tools/control-script.adoc

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1622,3 +1622,24 @@ Examples:
16221622
----
16231623
control.sh|bat --event list --enabled
16241624
----
1625+
1626+
== TLS Certificate Hot Reload
1627+
1628+
By default, TLS certificates are loaded once at node startup, so updating them requires a full node restart.
1629+
The `--ssl reload` command reloads certificates at runtime on all server nodes without a restart: each node
1630+
re-reads the key and trust stores from disk using its configured `SslContextFactory` and replaces the active
1631+
SSL context. New connections use the updated certificates, while sessions that were already established are not
1632+
interrupted.
1633+
1634+
[source, shell]
1635+
----
1636+
control.sh|bat --ssl reload
1637+
----
1638+
1639+
The command applies to the communication, discovery, thin client (client connector) and binary (TCP) REST transports
1640+
that have SSL enabled. Update the certificate files on disk (keeping the configured key and trust store paths) before
1641+
running the command.
1642+
1643+
NOTE: For the discovery transport, new outgoing connections use the reloaded certificates immediately, but the
1644+
already-bound listening socket keeps serving the previously loaded certificate to incoming connections until it is
1645+
recreated (for example, when the node re-enters the ring).

modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,4 +196,36 @@ protected void checkNullParameter(Object param, String name) throws SSLException
196196

197197
return ctx;
198198
}
199+
200+
/**
201+
* Rebuilds the SSL context from the current factory configuration, re-reading the configured key and trust
202+
* stores from disk, and replaces the instance cached by {@link #create()}. Used to hot-reload TLS certificates
203+
* at runtime without a node restart.
204+
*
205+
* @return Newly created SSL context.
206+
* @throws SSLException If the new context could not be created. In this case the previously cached context is
207+
* kept intact.
208+
*/
209+
public SSLContext reload() throws SSLException {
210+
SSLContext ctx = createSslContext();
211+
212+
sslCtx.set(ctx);
213+
214+
return ctx;
215+
}
216+
217+
/**
218+
* Rebuilds the SSL context produced by the given factory. If the factory supports hot reload (is an
219+
* {@link AbstractSslContextFactory}) its cached context is rebuilt from the configured key and trust stores;
220+
* otherwise {@link Factory#create()} is invoked.
221+
*
222+
* @param factory SSL context factory.
223+
* @return Reloaded SSL context.
224+
* @throws SSLException If the context could not be reloaded.
225+
*/
226+
public static SSLContext reload(Factory<SSLContext> factory) throws SSLException {
227+
return factory instanceof AbstractSslContextFactory
228+
? ((AbstractSslContextFactory)factory).reload()
229+
: factory.create();
230+
}
199231
}

modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/CommandHandlerParsingTest.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
import org.apache.ignite.internal.management.property.PropertyCommand;
8080
import org.apache.ignite.internal.management.snapshot.SnapshotCommand;
8181
import org.apache.ignite.internal.management.snapshot.SnapshotRestoreCommand;
82+
import org.apache.ignite.internal.management.ssl.SslCommand;
8283
import org.apache.ignite.internal.management.tx.TxCommand;
8384
import org.apache.ignite.internal.management.tx.TxCommandArg;
8485
import org.apache.ignite.internal.management.tx.TxSortOrder;
@@ -1214,6 +1215,7 @@ private boolean requireArgs(Class<?> cmd) {
12141215
cmd == PerformanceStatisticsCommand.class ||
12151216
cmd == ConsistencyCommand.class ||
12161217
cmd == CdcCommand.class ||
1217-
cmd == EventCommand.class;
1218+
cmd == EventCommand.class ||
1219+
cmd == SslCommand.class;
12181220
}
12191221
}

modules/core/src/main/java/org/apache/ignite/internal/management/IgniteCommandRegistry.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.apache.ignite.internal.management.persistence.PersistenceCommand;
3838
import org.apache.ignite.internal.management.property.PropertyCommand;
3939
import org.apache.ignite.internal.management.snapshot.SnapshotCommand;
40+
import org.apache.ignite.internal.management.ssl.SslCommand;
4041
import org.apache.ignite.internal.management.tx.TxCommand;
4142
import org.apache.ignite.internal.management.wal.WalCommand;
4243
import org.apache.ignite.internal.util.typedef.internal.U;
@@ -75,7 +76,8 @@ public IgniteCommandRegistry() {
7576
new PerformanceStatisticsCommand(),
7677
new CdcCommand(),
7778
new ConsistencyCommand(),
78-
new EventCommand()
79+
new EventCommand(),
80+
new SslCommand()
7981
);
8082

8183
U.loadService(CommandsProvider.class).forEach(p -> p.commands().forEach(this::register));
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.ignite.internal.management.ssl;
19+
20+
import org.apache.ignite.internal.management.api.CommandRegistryImpl;
21+
22+
/** SSL features command. */
23+
public class SslCommand extends CommandRegistryImpl {
24+
/** */
25+
public SslCommand() {
26+
super(
27+
new SslReloadCommand()
28+
);
29+
}
30+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.ignite.internal.management.ssl;
19+
20+
import java.util.Collection;
21+
import java.util.function.Consumer;
22+
import org.apache.ignite.cluster.ClusterNode;
23+
import org.apache.ignite.internal.management.api.CommandUtils;
24+
import org.apache.ignite.internal.management.api.ComputeCommand;
25+
import org.apache.ignite.internal.management.api.NoArg;
26+
import org.jetbrains.annotations.Nullable;
27+
28+
/** Reloads TLS certificates on all server nodes without a restart. */
29+
public class SslReloadCommand implements ComputeCommand<NoArg, String> {
30+
/** {@inheritDoc} */
31+
@Override public String description() {
32+
return "Reload TLS certificates on all server nodes by re-reading the configured key and trust stores. " +
33+
"New connections use the updated certificates while established sessions are not interrupted";
34+
}
35+
36+
/** {@inheritDoc} */
37+
@Override public Class<NoArg> argClass() {
38+
return NoArg.class;
39+
}
40+
41+
/** {@inheritDoc} */
42+
@Override public Class<SslReloadTask> taskClass() {
43+
return SslReloadTask.class;
44+
}
45+
46+
/** {@inheritDoc} */
47+
@Override public @Nullable Collection<ClusterNode> nodes(Collection<ClusterNode> nodes, NoArg arg) {
48+
return CommandUtils.servers(nodes);
49+
}
50+
51+
/** {@inheritDoc} */
52+
@Override public void printResult(NoArg arg, String res, Consumer<String> printer) {
53+
printer.accept(res);
54+
}
55+
56+
/** {@inheritDoc} */
57+
@Override public String confirmationPrompt(NoArg arg) {
58+
return "Warning: the command will reload TLS certificates on all server nodes.";
59+
}
60+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.ignite.internal.management.ssl;
19+
20+
import java.util.ArrayList;
21+
import java.util.List;
22+
import org.apache.ignite.IgniteCheckedException;
23+
import org.apache.ignite.IgniteException;
24+
import org.apache.ignite.compute.ComputeJobResult;
25+
import org.apache.ignite.internal.GridKernalContext;
26+
import org.apache.ignite.internal.management.api.NoArg;
27+
import org.apache.ignite.internal.processors.task.GridInternal;
28+
import org.apache.ignite.internal.util.nio.ssl.SslContextReloadable;
29+
import org.apache.ignite.internal.visor.VisorJob;
30+
import org.apache.ignite.internal.visor.VisorMultiNodeTask;
31+
import org.jetbrains.annotations.Nullable;
32+
33+
/** Task that reloads TLS certificates on every mapped node. */
34+
@GridInternal
35+
public class SslReloadTask extends VisorMultiNodeTask<NoArg, String, String> {
36+
/** */
37+
private static final long serialVersionUID = 0L;
38+
39+
/** {@inheritDoc} */
40+
@Override protected VisorJob<NoArg, String> job(NoArg arg) {
41+
return new SslReloadJob(arg, debug);
42+
}
43+
44+
/** {@inheritDoc} */
45+
@Override protected @Nullable String reduce0(List<ComputeJobResult> results) throws IgniteException {
46+
StringBuilder res = new StringBuilder();
47+
48+
for (ComputeJobResult jobRes : results) {
49+
if (jobRes.getException() != null)
50+
throw jobRes.getException();
51+
52+
res.append(jobRes.getData().toString()).append('\n');
53+
}
54+
55+
return res.toString();
56+
}
57+
58+
/** Job that reloads TLS certificates on the local node. */
59+
private static class SslReloadJob extends VisorJob<NoArg, String> {
60+
/** */
61+
private static final long serialVersionUID = 0L;
62+
63+
/** */
64+
protected SslReloadJob(NoArg arg, boolean debug) {
65+
super(arg, debug);
66+
}
67+
68+
/** {@inheritDoc} */
69+
@Override protected String run(NoArg arg) throws IgniteException {
70+
GridKernalContext ctx = ignite.context();
71+
72+
List<String> reloaded = new ArrayList<>();
73+
74+
try {
75+
reload(ctx.config().getCommunicationSpi(), "communication", reloaded);
76+
reload(ctx.config().getDiscoverySpi(), "discovery", reloaded);
77+
reload(ctx.clientListener(), "client connector", reloaded);
78+
reload(ctx.rest(), "REST", reloaded);
79+
}
80+
catch (IgniteCheckedException e) {
81+
throw new IgniteException("Failed to reload SSL certificates on node " +
82+
ignite.localNode().id() + ": " + e.getMessage(), e);
83+
}
84+
85+
return ignite.localNode().id() + ": " +
86+
(reloaded.isEmpty() ? "SSL is not configured, nothing to reload" : "reloaded " + reloaded);
87+
}
88+
89+
/**
90+
* Reloads the SSL context of the given component if it supports hot reload.
91+
*
92+
* @param comp Component to reload.
93+
* @param name Human-readable component name for the result message.
94+
* @param out Collects names of the components that were actually reloaded.
95+
* @throws IgniteCheckedException If the reload failed.
96+
*/
97+
private static void reload(Object comp, String name, List<String> out) throws IgniteCheckedException {
98+
if (comp instanceof SslContextReloadable && ((SslContextReloadable)comp).reloadSslContext())
99+
out.add(name);
100+
}
101+
}
102+
}

modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import javax.management.JMException;
3535
import javax.management.ObjectName;
3636
import javax.net.ssl.SSLContext;
37+
import javax.net.ssl.SSLException;
3738
import org.apache.ignite.IgniteCheckedException;
3839
import org.apache.ignite.IgniteException;
3940
import org.apache.ignite.configuration.ClientConnectorConfiguration;
@@ -57,6 +58,7 @@
5758
import org.apache.ignite.internal.util.nio.GridNioServer;
5859
import org.apache.ignite.internal.util.nio.GridNioSession;
5960
import org.apache.ignite.internal.util.nio.ssl.GridNioSslFilter;
61+
import org.apache.ignite.internal.util.nio.ssl.SslContextReloadable;
6062
import org.apache.ignite.internal.util.typedef.F;
6163
import org.apache.ignite.internal.util.typedef.internal.U;
6264
import org.apache.ignite.internal.util.worker.GridWorker;
@@ -68,6 +70,7 @@
6870
import org.apache.ignite.spi.IgnitePortProtocol;
6971
import org.apache.ignite.spi.systemview.view.ClientConnectionAttributeView;
7072
import org.apache.ignite.spi.systemview.view.ClientConnectionView;
73+
import org.apache.ignite.ssl.AbstractSslContextFactory;
7174
import org.jetbrains.annotations.NotNull;
7275
import org.jetbrains.annotations.Nullable;
7376

@@ -84,7 +87,7 @@
8487
/**
8588
* Client connector processor.
8689
*/
87-
public class ClientListenerProcessor extends GridProcessorAdapter {
90+
public class ClientListenerProcessor extends GridProcessorAdapter implements SslContextReloadable {
8891
/** */
8992
public static final String CLI_CONN_VIEW = metricName("client", "connections");
9093

@@ -118,6 +121,9 @@ public class ClientListenerProcessor extends GridProcessorAdapter {
118121
/** TCP Server. */
119122
private GridNioServer<ClientMessage> srv;
120123

124+
/** SSL filter of the current server, {@code null} if SSL is disabled. Used to hot-reload certificates. */
125+
private volatile GridNioSslFilter sslFilter;
126+
121127
/** Metrics. */
122128
private ClientListenerMetrics metrics;
123129

@@ -504,6 +510,8 @@ else if (connCtx.managementClient()) {
504510
sslFilter.wantClientAuth(auth);
505511
sslFilter.needClientAuth(auth);
506512

513+
this.sslFilter = sslFilter;
514+
507515
return new GridNioFilter[] {
508516
openSesFilter,
509517
codecFilter,
@@ -518,6 +526,32 @@ else if (connCtx.managementClient()) {
518526
}
519527
}
520528

529+
/** {@inheritDoc} */
530+
@Override public boolean reloadSslContext() throws IgniteCheckedException {
531+
ClientConnectorConfiguration cliConnCfg = ctx.config().getClientConnectorConfiguration();
532+
533+
GridNioSslFilter filter = sslFilter;
534+
535+
if (cliConnCfg == null || !cliConnCfg.isSslEnabled() || filter == null)
536+
return false;
537+
538+
Factory<SSLContext> sslCtxFactory = cliConnCfg.isUseIgniteSslContextFactory()
539+
? ctx.config().getSslContextFactory()
540+
: cliConnCfg.getSslContextFactory();
541+
542+
if (sslCtxFactory == null)
543+
return false;
544+
545+
try {
546+
filter.updateSslContext(AbstractSslContextFactory.reload(sslCtxFactory));
547+
}
548+
catch (SSLException e) {
549+
throw new IgniteCheckedException("Failed to reload SSL context for client connector connections.", e);
550+
}
551+
552+
return true;
553+
}
554+
521555
/** {@inheritDoc} */
522556
@Override public void onKernalStop(boolean cancel) {
523557
if (srv != null) {

0 commit comments

Comments
 (0)