com.couchbase.client.core.util.ConnectionStringUtil Maven / Gradle / Ivy
Show all versions of core-io Show documentation
/*
* Copyright (c) 2019 Couchbase, Inc.
*
* 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
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.couchbase.client.core.util;
import com.couchbase.client.core.annotation.Stability;
import com.couchbase.client.core.cnc.Event;
import com.couchbase.client.core.cnc.EventBus;
import com.couchbase.client.core.cnc.events.config.TlsRequiredButNotEnabledEvent;
import com.couchbase.client.core.cnc.events.core.DnsSrvLookupDisabledEvent;
import com.couchbase.client.core.cnc.events.core.DnsSrvLookupFailedEvent;
import com.couchbase.client.core.cnc.events.core.DnsSrvRecordsLoadedEvent;
import com.couchbase.client.core.env.CoreEnvironment;
import com.couchbase.client.core.env.SeedNode;
import com.couchbase.client.core.error.InvalidArgumentException;
import com.couchbase.client.core.util.ConnectionString.PortType;
import com.couchbase.client.core.util.ConnectionString.UnresolvedSocket;
import javax.naming.NameNotFoundException;
import javax.naming.NamingException;
import java.net.SocketTimeoutException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static com.couchbase.client.core.logging.RedactableArgument.redactSystem;
import static com.couchbase.client.core.util.ConnectionString.PortType.KV;
import static com.couchbase.client.core.util.ConnectionString.PortType.MANAGER;
import static com.couchbase.client.core.util.ConnectionString.PortType.PROTOSTELLAR;
import static com.couchbase.client.core.util.ConnectionString.Scheme.COUCHBASE;
import static com.couchbase.client.core.util.ConnectionString.Scheme.COUCHBASES;
import static java.util.stream.Collectors.groupingBy;
/**
* Contains various helper methods when dealing with the connection string.
*/
@Stability.Internal
public class ConnectionStringUtil {
private ConnectionStringUtil() {
}
/**
* Populates a list of seed nodes from the connection string.
*
* Note that this method also performs DNS SRV lookups if the connection string qualifies!
*
* @param connectionString the connection string in its encoded form.
* @param dnsSrvEnabled true if dns srv is enabled.
* @param tlsEnabled true if tls is enabled.
* @return a set of seed nodes populated.
*/
public static Set seedNodesFromConnectionString(final ConnectionString connectionString, final boolean dnsSrvEnabled,
final boolean tlsEnabled, final EventBus eventBus) {
if (dnsSrvEnabled && connectionString.isValidDnsSrv()) {
String srvHostname = connectionString.hosts().get(0).host();
NanoTimestamp start = NanoTimestamp.now();
try {
// Technically a hostname with the _couchbase._tcp. (and the tls equivalent) does not qualify for
// a connection string, but we can be good citizens and remove it so the user can still bootstrap.
if (srvHostname.startsWith(DnsSrv.DEFAULT_DNS_SERVICE)) {
srvHostname = srvHostname.replace(DnsSrv.DEFAULT_DNS_SERVICE, "");
} else if (srvHostname.startsWith(DnsSrv.DEFAULT_DNS_SECURE_SERVICE)) {
srvHostname = srvHostname.replace(DnsSrv.DEFAULT_DNS_SECURE_SERVICE, "");
}
final List foundNodes = fromDnsSrvOrThrowIfTlsRequired(srvHostname, tlsEnabled);
if (foundNodes.isEmpty()) {
throw new IllegalStateException("The loaded DNS SRV list from " + srvHostname + " is empty!");
}
Duration took = start.elapsed();
eventBus.publish(new DnsSrvRecordsLoadedEvent(took, foundNodes));
return foundNodes.stream().map(SeedNode::create).collect(Collectors.toSet());
} catch (InvalidArgumentException t) {
throw t;
} catch (Throwable t) {
Duration took = start.elapsed();
if (t instanceof NameNotFoundException) {
eventBus.publish(new DnsSrvLookupFailedEvent(
Event.Severity.INFO,
took,
null,
DnsSrvLookupFailedEvent.Reason.NAME_NOT_FOUND)
);
} else if (t.getCause() instanceof SocketTimeoutException) {
eventBus.publish(new DnsSrvLookupFailedEvent(
Event.Severity.INFO,
took,
null,
DnsSrvLookupFailedEvent.Reason.TIMED_OUT)
);
} else {
eventBus.publish(new DnsSrvLookupFailedEvent(
Event.Severity.WARN,
took,
t,
DnsSrvLookupFailedEvent.Reason.OTHER
));
}
return populateSeedsFromConnectionString(connectionString);
}
} else {
eventBus.publish(new DnsSrvLookupDisabledEvent(dnsSrvEnabled, connectionString.isValidDnsSrv()));
return populateSeedsFromConnectionString(connectionString);
}
}
/**
* Extracts the seed nodes from the instantiated connection string.
* Assumes untyped ports are KV ports.
*
* If a host appears in the connection string multiple times, all its ports are merged.
* For example: {@code "couchbases://foo:123,foo:456=manager"} yields a single node
* with KV port 123 and Manager port 456. If the cluster actually has multiple nodes
* on the same host, some are ignored. While not ideal, this is good enough for bootstrapping.
*
* @param connectionString the instantiated connection string.
* @return the set of seed nodes extracted.
*/
static Set populateSeedsFromConnectionString(final ConnectionString connectionString) {
Map> groupedByHost = connectionString.hosts().stream()
.collect(groupingBy(UnresolvedSocket::host));
Set seedNodes = new HashSet<>();
groupedByHost.forEach((host, addresses) -> {
Map ports = new EnumMap<>(PortType.class);
PortType assumedPortType = connectionString.scheme() == ConnectionString.Scheme.COUCHBASE2 ? PortType.PROTOSTELLAR : PortType.KV;
addresses.stream()
.filter(it -> it.port() != 0)
.forEach(it -> {
if (connectionString.scheme() == ConnectionString.Scheme.COUCHBASE2 && (it.portType().isPresent() && it.portType().get() != PROTOSTELLAR)) {
throw InvalidArgumentException.fromMessage("Invalid connection string. Port type " + it.portType().get() + " is not compatible with scheme " + connectionString.scheme());
}
ports.put(it.portType().orElse(assumedPortType), it.port());
});
seedNodes.add(SeedNode.create(host)
.withKvPort(ports.get(KV))
.withManagerPort(ports.get(MANAGER))
.withProtostellarPort(ports.get(PROTOSTELLAR))
);
});
sanityCheckSeedNodes(connectionString.original(), seedNodes);
return seedNodes;
}
/**
* Sanity check the seed node list for common errors that can be caught early on.
*
* @param seedNodes the seed nodes to verify.
*/
private static void sanityCheckSeedNodes(final String connectionString, final Set seedNodes) {
for (SeedNode seedNode : seedNodes) {
if (seedNode.kvPort().isPresent()) {
if (seedNode.kvPort().get() == 8091 || seedNode.kvPort().get() == 18091) {
String recommended = connectionString
.replace(":8091", "")
.replace(":18091", "");
throw new InvalidArgumentException("Specifying 8091 or 18091 in the connection string \"" + connectionString + "\" is " +
"likely not what you want (it would connect to key/value via the management port which does not work). Please omit " +
"the port and use \"" + recommended + "\" instead.", null, null);
}
}
}
}
/**
* Returns true if the addresses indicate this is a Couchbase Capella cluster.
*/
public static boolean isCapella(ConnectionString connectionString) {
return connectionString.hosts().stream()
.allMatch(it -> it.host().endsWith(".cloud.couchbase.com"));
}
/**
* Returns a synthetic connection string corresponding to the seed nodes.
*/
public static ConnectionString asConnectionString(Collection nodes) {
boolean hasProtostellarPort = nodes.stream().anyMatch(it -> it.protostellarPort().isPresent());
boolean hasClassicPort = nodes.stream().anyMatch(it -> !it.protostellarPort().isPresent());
if (hasClassicPort && hasProtostellarPort) {
throw InvalidArgumentException.fromMessage("The seed nodes have an invalid combination of port types. Must be all Protostellar or all KV/Manager.");
}
List addresses = new ArrayList<>();
for (SeedNode node : nodes) {
if (node.protostellarPort().isPresent()) {
addresses.add(new HostAndPort(node.address(), node.protostellarPort().get()).format());
continue;
}
if (!node.kvPort().isPresent() && !node.clusterManagerPort().isPresent()) {
// Seed node did not specify any port.
addresses.add(new HostAndPort(node.address(), 0).format());
continue;
}
// Node has one or both of KV and Manager ports. If both, repeat the host
// and let populateSeedsFromConnectionString reunify the "split" seed node later.
node.kvPort().ifPresent(port -> addresses.add(new HostAndPort(node.address(), port).format() + "=kv"));
node.clusterManagerPort().ifPresent(port -> addresses.add(new HostAndPort(node.address(), port).format() + "=manager"));
}
return ConnectionString.create(String.join(",", addresses))
.withScheme(hasProtostellarPort ? ConnectionString.Scheme.COUCHBASE2 : COUCHBASE);
}
public static final String INCOMPATIBLE_CONNECTION_STRING_SCHEME =
"Connection string scheme indicates a secure connection," +
" but the pre-built ClusterEnvironment was not configured for TLS.";
public static final String INCOMPATIBLE_CONNECTION_STRING_PARAMS =
"Can't use a pre-built ClusterEnvironment with a connection string that has parameters.";
public static void checkConnectionString(CoreEnvironment env, boolean ownsEnvironment, ConnectionString connStr) {
boolean tls = env.securityConfig().tlsEnabled();
if (!ownsEnvironment) {
if (!tls && connStr.scheme() == COUCHBASES) {
throw new IllegalArgumentException(INCOMPATIBLE_CONNECTION_STRING_SCHEME);
}
if (!tls && connStr.scheme() == ConnectionString.Scheme.COUCHBASE2) {
throw new IllegalArgumentException(INCOMPATIBLE_CONNECTION_STRING_SCHEME);
}
if (!connStr.params().isEmpty()) {
throw new IllegalArgumentException(INCOMPATIBLE_CONNECTION_STRING_PARAMS);
}
}
boolean capella = isCapella(connStr);
if (capella && !tls) {
// Can't connect to Capella without TLS. Until we determine
// a better way of detecting and handling this, log a warning
// and continue marching straight off the cliff.
env.eventBus().publish(ownsEnvironment
? TlsRequiredButNotEnabledEvent.forOwnedEnvironment()
: TlsRequiredButNotEnabledEvent.forSharedEnvironment()
);
}
}
@Stability.Internal
public static List fromDnsSrvOrThrowIfTlsRequired(final String serviceName, boolean secure) throws NamingException {
final boolean full = false;
// If the user enabled TLS, just return records for the secure protocol.
if (secure) {
return DnsSrv.fromDnsSrv(serviceName, full, true);
}
try {
// User didn't enable TLS, so try to return records for the insecure protocol.
return DnsSrv.fromDnsSrv(serviceName, full, false);
} catch (NameNotFoundException errorFromFirstLookup) {
// There's no DNS SRV record for the insecure protocol.
// If there's one for the secure protocol, tell the user TLS is required.
try {
if (!DnsSrv.fromDnsSrv(serviceName, full, true).isEmpty()) {
throw InvalidArgumentException.fromMessage(
"The DNS SRV record for '" + redactSystem(serviceName) + "' indicates" +
" TLS must be used when connecting to this cluster." +
" Please enable TLS by setting the 'security.enableTLS' client setting to true." +
" If the Cluster does not use a shared ClusterEnvironment, an alternative way to enable TLS is to" +
" prefix the connection string with \"couchbases://\" (note the final 's')");
}
throw errorFromFirstLookup;
} catch (InvalidArgumentException propagateMe) {
throw propagateMe;
} catch (Exception e) {
if (e != errorFromFirstLookup) {
errorFromFirstLookup.addSuppressed(e);
}
throw errorFromFirstLookup;
}
}
}
}