Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
1. The [**Java Nats Examples**](https://github.com/nats-io/java-nats-examples) GitHub repo, a collection of simple use case examples.

## Table of Contents
* [Important](#important)
* [Client and Orbit](#client-and-orbit)
* [Simplification](#simplification)
* [Consumer Info Calls](#consumer-info-calls)
Expand All @@ -40,6 +41,10 @@
* [Examples](#examples)
* [License](#license)

## Important

Version 2.26.5 turns TLS hostname verification on by default, so a connection to a server whose certificate does not name the host in the url now fails. See [Version 2.26.5 Hostname Verification On By Default](#version-2265-hostname-verification-on-by-default) for who is affected and how to opt out.

## Client and Orbit

NATS client functionality is split across two layers: the **core client**
Expand Down Expand Up @@ -204,6 +209,32 @@ rather than shrinking the general `executor`.

## Recent Version Notes

### Version 2.26.5 Hostname Verification On By Default

Starting with 2.26.5, the client checks that the certificate a server presents was issued for the host in the url you connect with. Before 2.26.5 it did not: any certificate your trust store accepted was accepted for any host, and `opentls` accepted any certificate at all. The check applies to every TLS connection, including `opentls`, and to servers the client discovers from the cluster, not only the ones in your options.

When the certificate does not name the host, the connection fails and the error names the host it was looking for, for example `No subject alternative names matching IP address 10.0.0.5 found`.

You are not affected if you do not use TLS, or if you connect by a name, such as `tls://nats.example.com:4222`, and the certificate was issued for that name. This is the normal case with a certificate from a public or private CA.

You are affected if:

1. You connect by ip address, such as `tls://10.0.0.5:4222`, and the certificate does not list that ip address as a subject alternative name. Certificates issued for a hostname usually do not.
2. You connect by a name the certificate does not carry: a load balancer or proxy address, an internal alias, or a Kubernetes service name, in front of servers whose certificate names something else.
3. You use `opentls` or a trust-all context against a certificate that does not name the host you connect with. The common case is development against a self-signed certificate for `localhost` while connecting by `127.0.0.1`, or the other way round.
4. Your cluster advertises its servers by ip address and each server has its own certificate for its own name. Reconnects to the advertised ips are checked against the name you connected with.
5. You supply your own `SSLContext`. The check applies to it too, unless your trust manager is an `X509ExtendedTrustManager`, which is left to do its own identity check.

To check a certificate, list its subject alternative names and compare them with the host in your url:

```
openssl x509 -in server.pem -noout -ext subjectAltName
```

If you are affected, connect by a name the certificate carries, or reissue the certificate with the names and ip addresses you connect with. For a cluster that advertises ips, set `client_advertise` on the servers so they advertise hostnames instead.

To keep the old behavior, call `tlsVerifyHostname(false)` on the options builder, or set the property `io.nats.client.tls.verify.hostname=false`. This removes the proof that the server is the one named in the url, so treat it as a development setting, not a fix. The full rules are in [TLS configuration](docs/TLS_CONFIGURATION.md).

### Version 2.26.0 Advanced Request Behavior

This release adds an opt-in connection option, `advancedRequestBehavior`, that surfaces *why* a request
Expand Down Expand Up @@ -705,6 +736,8 @@ When options are built, the SSLContext will be accepted or created in the follow
3. If `opentls` is true or any of the bootstrap servers has `opentls` as their scheme, a generic SSLContext will be created that **"trusts all certs"**. Asking for both, by setting `secure` and `opentls`, or with a bootstrap list that mixes `opentls` with `tls` or `wss`, is rejected; see [TLS configuration](docs/TLS_CONFIGURATION.md).
4. If `secure` is true or any of the bootstrap servers has `tls` or `wss` as their scheme, the `javax.net.ssl.SSLContext.getDefault()` will be used.

When the options end up with an SSLContext by any of these routes, a bootstrap server with the `ws` scheme is rejected when the options are built, because a `ws` connection is never upgraded to TLS. Use `wss` for that server. A `ws` server the cluster advertises in `connect_urls` is skipped when the pool hands it out, reported through the error listener's `serverRejected`, and dropped from the pool; see [TLS configuration](docs/TLS_CONFIGURATION.md).

### Publishing

Once connected, publishing is accomplished via one of three methods:
Expand Down
2 changes: 2 additions & 0 deletions docs/TLS_CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ The context is accepted or created once, when `Options.Builder.build()` runs, in

Rules 4 and 5 look at the bootstrap servers only when neither `opentls` nor `secure` was set explicitly. Asking for both is an error, section 5.

When the options end up with a context, by any of rules 1 to 5, a `ws://` server in the list is an error at `build()`: `ws://` is a plain websocket and is never upgraded to TLS, so that server would be reached without it. Use `wss://` for it. A `nats://` server is allowed, because it is upgraded to TLS like every other server of the connection. A `ws://` server that reaches the connection another way, advertised by the cluster in `connect_urls` or supplied by a custom `ServerPool`, is never dialed: when the pool hands it out, the connection skips it and goes on to the next server, reports it through `ErrorListener.serverRejected`, and tells the pool through `ServerPool.serverRejected`, so the default pool drops it and does not take it back from a later `connect_urls`.

## 2. The default context: `tls://`, `wss://`, `secure()`

`SSLContext.getDefault()` is the JVM's default: it verifies the server's certificate chain against the JVM trust store, `cacerts` in the JDK's `lib/security`, or whatever the `javax.net.ssl.trustStore` system properties point at. It presents the client certificate from `javax.net.ssl.keyStore` when a server asks for one. It does not read the operating system's certificate store; a CA installed in Windows or macOS is not trusted by the JVM unless it is also in the JVM trust store or the JVM is configured to use the system store.
Expand Down
14 changes: 13 additions & 1 deletion src/main/java/io/nats/client/ErrorListener.java
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2015-2018 The NATS Authors
// Copyright 2015-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at:
Expand Down Expand Up @@ -150,6 +150,18 @@ default void flowControlProcessed(Connection conn, JetStreamSubscription sub, St
*/
default void socketWriteTimeout(Connection conn) {}

/**
* Called by the connection when the server pool supplied a server that the connection will never attempt,
* because it cannot be used with the options: for example a plain ws:// server when the options carry
* an SSL context, since a plain websocket is never upgraded to TLS. The connection goes on to the next
* server, and the pool is told through ServerPool.serverRejected so it stops supplying the server.
*
* @param conn The connection that rejected the server
* @param server The server that was rejected, as a url
* @param reason Why it was rejected
*/
default void serverRejected(Connection conn, String server, String reason) {}

/**
* General message producing function which understands the possible parameters to listener calls.
* @param label the label for the message
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/io/nats/client/Options.java
Original file line number Diff line number Diff line change
Expand Up @@ -2507,6 +2507,16 @@ else if (useDefaultTls) {
}
}

// A connection with an SSL context requires TLS to every server, but a ws:// server is a plain
// websocket and is never upgraded, so it would be reached without TLS. nats:// servers are upgraded.
if (sslContext != null) {
for (NatsUri natsUri : natsServerUris) {
if (WEBSOCKET_PROTOCOL.equals(natsUri.getScheme())) {
throw new IllegalStateException("Options require TLS (secure, opentls, an SSL context, keystore or truststore, an SSL context factory, or a tls, wss or opentls server url), but the server list has a ws:// server, which is never upgraded to TLS. Use wss:// for that server.");
}
}
}

if (tlsFirst && sslContext == null) {
throw new IllegalStateException("SSL context required for tls handshake first");
}
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/io/nats/client/ServerPool.java
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,18 @@ default List<String> resolveHostToIps(@NonNull String host, boolean maxOneResult
*/
void connectFailed(@NonNull NatsUri nuri);

/**
* The connection will never attempt the server, because it cannot be used with the options:
* for example a plain ws:// server when the options carry an SSL context, since a plain websocket
* is never upgraded to TLS. The connection has already taken the server from the pool and goes on
* to the next one. The pool should stop supplying the server, including when it is discovered again.
* The default implementation does nothing. A pool that keeps supplying it does not make the connection
* attempt it: when the pool supplies a rejected server again and has supplied nothing usable since,
* the connection ends that connect or reconnect.
* @param nuri the server that was rejected
*/
default void serverRejected(@NonNull NatsUri nuri) {}

/**
* Get the list of servers known to the pool. Purely informational
* @return the list of servers
Expand Down
10 changes: 9 additions & 1 deletion src/main/java/io/nats/client/impl/ErrorListenerConsoleImpl.java
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2024 The NATS Authors
// Copyright 2024-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at:
Expand Down Expand Up @@ -98,4 +98,12 @@ public void flowControlProcessed(Connection conn, JetStreamSubscription sub, Str
public void socketWriteTimeout(Connection conn) {
System.out.println(supplyMessage("[SEVERE] socketWriteTimeout", conn, null, null));
}

/**
* {@inheritDoc}
*/
@Override
public void serverRejected(Connection conn, String server, String reason) {
System.out.println(supplyMessage("[WARNING] serverRejected", conn, null, null, "Server: ", server, "Reason: ", reason));
}
}
10 changes: 9 additions & 1 deletion src/main/java/io/nats/client/impl/ErrorListenerLoggerImpl.java
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2021 The NATS Authors
// Copyright 2021-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at:
Expand Down Expand Up @@ -102,4 +102,12 @@ public void flowControlProcessed(Connection conn, JetStreamSubscription sub, Str
public void socketWriteTimeout(Connection conn) {
LOGGER.severe(() -> supplyMessage("socketWriteTimeout", conn, null, null));
}

/**
* {@inheritDoc}
*/
@Override
public void serverRejected(Connection conn, String server, String reason) {
LOGGER.warning(() -> supplyMessage("serverRejected", conn, null, null, "Server: ", server, "Reason: ", reason));
}
}
50 changes: 50 additions & 0 deletions src/main/java/io/nats/client/impl/NatsConnection.java
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,25 @@ protected void connectImpl(boolean reconnectOnConnect) throws InterruptedExcepti
boolean keepGoing = true;
NatsUri first = null;
NatsUri cur;
// A rejected server, mapped to the count of usable servers handed out when it was last supplied.
// The pool is told to drop a rejected server; if it supplies one again with nothing usable in between, it has nothing else.
Map<NatsUri, Long> rejected = new HashMap<>();
long usableCount = 0;
while (keepGoing && (cur = serverPool.peekNextServer()) != null) {
String rejection = rejectionReason(cur);
if (rejection != null) {
serverPool.nextServer(); // b/c we only peeked, and the pool must have handed it out before it is told
Long usableAtLastRejection = rejected.put(cur, usableCount);
if (usableAtLastRejection == null) {
rejectServer(cur, rejection);
failList.add(cur);
}
else if (usableAtLastRejection == usableCount) {
break; // the pool kept a rejected server and handed out nothing usable since it last supplied it
}
continue; // before first is set: a rejected server must not end the pass
}
usableCount++;
if (first == null) {
first = cur;
}
Expand Down Expand Up @@ -472,7 +490,22 @@ protected void reconnectImplConnect() throws InterruptedException {
int totalRounds = 0;
NatsUri first = null;
NatsUri cur;
// see connect: a rejected server, mapped to the count of usable servers handed out when it was last supplied
Map<NatsUri, Long> rejected = new HashMap<>();
long usableCount = 0;
while ((cur = serverPool.nextServer()) != null) {
String rejection = rejectionReason(cur);
if (rejection != null) {
Long usableAtLastRejection = rejected.put(cur, usableCount);
if (usableAtLastRejection == null) {
rejectServer(cur, rejection);
}
else if (usableAtLastRejection == usableCount) {
return; // the pool kept a rejected server and handed out nothing usable since it last supplied it
}
continue; // before first is set and before the delay: a rejected server is not an attempt
}
usableCount++;
if (first == null) {
first = cur;
if (options.reconnectDelayBehavior() == Options.ReconnectDelayBehavior.BeforeAllRounds) {
Expand Down Expand Up @@ -562,6 +595,23 @@ else if (remainingNanos < 1_000_000_000) {
// is called from reconnect and connect
// will wait for any previous attempt to complete, using the reader.stop and
// writer.stop
// A server the connection will never attempt because it cannot be used with the options: a ws:// server is
// a plain websocket that is never upgraded to TLS, so with an SSL context it would be reached without TLS.
// Both connect loops ask this when the pool hands a server out, before anything is sent to it. The bootstrap
// list cannot hold such a server, so one comes from connect_urls or from a custom ServerPool.
protected String rejectionReason(NatsUri nuri) {
if (options.isTLSRequired() && WEBSOCKET_PROTOCOL.equals(nuri.getScheme())) {
return "the options require TLS and a ws:// server is never upgraded to TLS";
}
return null;
}

// The pool has already handed the server out. Tell it so it stops supplying the server, and report it.
protected void rejectServer(NatsUri nuri, String reason) {
serverPool.serverRejected(nuri);
makeCallback(() -> options.getErrorListener().serverRejected(this, nuri.toString(), reason));
}

protected void tryToConnect(NatsUri cur, NatsUri resolved, long now) {
clearCurrentServer();
connectingServer = cur; // the origin of discovered servers in the initial INFO, before currentServer is set
Expand Down
34 changes: 33 additions & 1 deletion src/main/java/io/nats/client/impl/NatsServerPool.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ public class NatsServerPool implements ServerPool {
protected boolean hasSecureServer;
protected NatsUri lastConnected;
protected String defaultScheme;
protected final List<NatsUri> rejected;

public NatsServerPool() {
listLock = new ReentrantLock();
entryList = new ArrayList<>(); // this gets updated occasionally
rejected = new ArrayList<>();
options = Options.builder().build(); // this will get updated when initialize is called
}

Expand Down Expand Up @@ -113,10 +115,16 @@ public boolean acceptDiscoveredUrls(@NonNull List<@NonNull String> discoveredSer
// 2. Build a list for discovered
// - since we'll need the NatsUris later
// - and to have a list to use to prune removed gossiped servers
// - a server the connection rejected is never supplied again, so it is left out here.
// The match is the exact uri: the scheme is what made it unusable, so the same
// host and port under another scheme is still a candidate
List<NatsUri> discovered = new ArrayList<>();
for (String d : discoveredServers) {
try {
discovered.add(new NatsUri(d, defaultScheme));
NatsUri nuri = new NatsUri(d, defaultScheme);
if (!rejected.contains(nuri)) {
discovered.add(nuri);
}
} catch (URISyntaxException ignore) {
// should never actually happen
}
Expand Down Expand Up @@ -285,6 +293,30 @@ public void connectFailed(@NonNull NatsUri nuri) {
}
}

/**
* {@inheritDoc}
*/
@Override
public void serverRejected(@NonNull NatsUri nuri) {
// 1. Remember the exact uri so a later discovery does not bring it back
// 2. Remove it from the list. Work from the end because nextServer moved the one being tried to the end
listLock.lock();
try {
if (!rejected.contains(nuri)) {
rejected.add(nuri);
}
for (int x = entryList.size() - 1; x >= 0; x--) {
if (entryList.get(x).nuri.equals(nuri)) {
entryList.remove(x);
return;
}
}
}
finally {
listLock.unlock();
}
}

@Override
@NonNull
public List<String> getServerList() {
Expand Down
11 changes: 11 additions & 0 deletions src/test/java/io/nats/client/OptionsTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -1574,6 +1574,17 @@ public void testSslContextIsProvided() throws Exception {
assertNotNull(new Options.Builder().server("tls://localhost,opentls://localhost").opentls().build().getSslContext());
SSLContext ctx = SslTestingHelper.createTestSSLContext();
assertEquals(ctx, new Options.Builder().server("tls://localhost,opentls://localhost").sslContext(ctx).build().getSslContext());

// with TLS required, a ws:// server would be reached without TLS: a builder error. nats:// servers are upgraded.
assertThrows(IllegalStateException.class, () -> new Options.Builder().server("wss://a,ws://b").build());
assertThrows(IllegalStateException.class, () -> new Options.Builder().server("tls://a,ws://b").build());
assertThrows(IllegalStateException.class, () -> new Options.Builder().server("opentls://a,ws://b").build());
assertThrows(IllegalStateException.class, () -> new Options.Builder().server("ws://a").secure().build());
assertThrows(IllegalStateException.class, () -> new Options.Builder().server("ws://a").opentls().build());
assertThrows(IllegalStateException.class, () -> new Options.Builder().server("ws://a").sslContext(ctx).build());
assertNotNull(new Options.Builder().server("nats://a,tls://b").build().getSslContext());
assertNotNull(new Options.Builder().server("nats://a").secure().build().getSslContext());
assertNull(new Options.Builder().server("ws://a,nats://b").build().getSslContext());
}

@SuppressWarnings("deprecation")
Expand Down
Loading
Loading