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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Changelog for Management API, new PRs should update the `main / unreleased` sect
```

## unreleased
* [FEATURE] [#174](https://github.com/k8ssandra/management-api-for-apache-cassandra/issues/174) Allow restricting TLS client certificates to an allowlist of identities with `--tlsallowedclient` (`MGMT_API_TLS_ALLOWED_CLIENTS`, comma-separated, in the images)

## v0.1.127 [2026-10-09]
* [FEATURE] [#793](https://github.com/k8ssandra/management-api-for-apache-cassandra/issues/793) Provide VirtualTable to query the status of node's jobs
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,18 @@ the environment variable `MGMT_API_LISTEN_TCP_PORT` and setting it to your desir

The above would run a Cassandra 4.0.15 image with Management API listening on port 9090 (instead of 8080).

### Restricting TLS clients

When TLS is enabled with `MGMT_API_TLS_CA_CERT_FILE`, `MGMT_API_TLS_CERT_FILE` and `MGMT_API_TLS_KEY_FILE`, any client
certificate signed by the CA is accepted. To only accept specific clients, set `MGMT_API_TLS_ALLOWED_CLIENTS` to a
comma-separated list of identities, each matched against the client certificate subject CN and its DNS/URI/email/IP
subject alternative names (see `--tlsallowedclient` below). For example:

> docker run -e USE_MGMT_API=true -e MGMT_API_TLS_CA_CERT_FILE=/certs/ca.crt -e MGMT_API_TLS_CERT_FILE=/certs/tls.crt \
-e MGMT_API_TLS_KEY_FILE=/certs/tls.key -e MGMT_API_TLS_ALLOWED_CLIENTS="cass-operator,Reaper Client" ...

Spaces inside an identity are kept, so do not put spaces around the commas.

### Virtual tables (Cassandra 5.0 and newer)

The agent registers the read-only `management_api.jobs` and `management_api.job_events`
Expand Down Expand Up @@ -674,6 +686,7 @@ Once the tag is pushed, the release process will start and build the Docker imag
[ {-K | --no-keep-alive} <no_keep_alive> ]
[ {-p | --pidfile} <pidfile> ]
{-S | --cassandra-socket | --db-socket} <db_unix_socket_file>
[ --tlsallowedclient <tls_allowed_clients>... ]
[ --tlscacert <tls_ca_cert_file> ]
[ --tlscert <tls_cert_file> ] [ --tlskey <tls_key_file> ]

Expand Down Expand Up @@ -715,6 +728,13 @@ Once the tag is pushed, the release process will start and build the Docker imag
readable and writable.


--tlsallowedclient <tls_allowed_clients>
Only accept TLS clients whose certificate subject CN or a
DNS/URI/email/IP subject alternative name matches this value,
can be repeated. DNS names match case-insensitively and IP
addresses in any notation, everything else must match exactly.
If not set, any client certificate signed by the CA is accepted

--tlscacert <tls_ca_cert_file>
Path to trust certs signed only by this CA

Expand Down
15 changes: 13 additions & 2 deletions cassandra-trunk/files/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,17 @@ if [ "$1" = 'mgmtapi' ]; then
MGMT_API_TLS_KEY_FILE="--tlskey $MGMT_API_TLS_KEY_FILE"
MGMT_API_ARGS="$MGMT_API_ARGS $MGMT_API_TLS_KEY_FILE"
fi
# Kept out of MGMT_API_ARGS because identities may contain spaces or glob characters
MGMT_API_TLS_ALLOWED_CLIENT_ARGS=()
if [ ! -z "$MGMT_API_TLS_ALLOWED_CLIENTS" ]; then
# read only consumes one line, so treat newlines in multi-line values as separators too
IFS=',' read -r -a MGMT_API_TLS_ALLOWED_CLIENT_LIST <<< "${MGMT_API_TLS_ALLOWED_CLIENTS//$'\n'/,}"
for MGMT_API_TLS_ALLOWED_CLIENT in "${MGMT_API_TLS_ALLOWED_CLIENT_LIST[@]}"; do
if [ ! -z "$MGMT_API_TLS_ALLOWED_CLIENT" ]; then
MGMT_API_TLS_ALLOWED_CLIENT_ARGS+=(--tlsallowedclient "$MGMT_API_TLS_ALLOWED_CLIENT")
fi
done
fi

if [ ! -z "$MGMT_API_PID_FILE" ]; then
MGMT_API_PID_FILE="--pidfile $MGMT_API_PID_FILE"
Expand All @@ -200,8 +211,8 @@ if [ "$1" = 'mgmtapi' ]; then

# use default of 128m heap if env variable not set
: "${MGMT_API_HEAP_SIZE:=128m}"
echo "Running" java ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS
java ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS
echo "Running" java ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS "${MGMT_API_TLS_ALLOWED_CLIENT_ARGS[@]}"
java ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS "${MGMT_API_TLS_ALLOWED_CLIENT_ARGS[@]}"
fi

exec "$@"
15 changes: 13 additions & 2 deletions cassandra/scripts/docker-entrypoint-jdk8.sh
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,17 @@ if [ "$1" = 'mgmtapi' ]; then
MGMT_API_TLS_KEY_FILE="--tlskey $MGMT_API_TLS_KEY_FILE"
MGMT_API_ARGS="$MGMT_API_ARGS $MGMT_API_TLS_KEY_FILE"
fi
# Kept out of MGMT_API_ARGS because identities may contain spaces or glob characters
MGMT_API_TLS_ALLOWED_CLIENT_ARGS=()
if [ ! -z "$MGMT_API_TLS_ALLOWED_CLIENTS" ]; then
# read only consumes one line, so treat newlines in multi-line values as separators too
IFS=',' read -r -a MGMT_API_TLS_ALLOWED_CLIENT_LIST <<< "${MGMT_API_TLS_ALLOWED_CLIENTS//$'\n'/,}"
for MGMT_API_TLS_ALLOWED_CLIENT in "${MGMT_API_TLS_ALLOWED_CLIENT_LIST[@]}"; do
if [ ! -z "$MGMT_API_TLS_ALLOWED_CLIENT" ]; then
MGMT_API_TLS_ALLOWED_CLIENT_ARGS+=(--tlsallowedclient "$MGMT_API_TLS_ALLOWED_CLIENT")
fi
done
fi

if [ ! -z "$MGMT_API_PID_FILE" ]; then
MGMT_API_PID_FILE="--pidfile $MGMT_API_PID_FILE"
Expand Down Expand Up @@ -211,8 +222,8 @@ if [ "$1" = 'mgmtapi' ]; then
JAVA11_JAVA=${JAVA11_HOME}/bin/java
fi
fi
echo "Running" ${JAVA11_JAVA} ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS
${JAVA11_JAVA} ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS
echo "Running" ${JAVA11_JAVA} ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS "${MGMT_API_TLS_ALLOWED_CLIENT_ARGS[@]}"
${JAVA11_JAVA} ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS "${MGMT_API_TLS_ALLOWED_CLIENT_ARGS[@]}"
fi

exec "$@"
15 changes: 13 additions & 2 deletions cassandra/scripts/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,17 @@ if [ "$1" = 'mgmtapi' ]; then
MGMT_API_TLS_KEY_FILE="--tlskey $MGMT_API_TLS_KEY_FILE"
MGMT_API_ARGS="$MGMT_API_ARGS $MGMT_API_TLS_KEY_FILE"
fi
# Kept out of MGMT_API_ARGS because identities may contain spaces or glob characters
MGMT_API_TLS_ALLOWED_CLIENT_ARGS=()
if [ ! -z "$MGMT_API_TLS_ALLOWED_CLIENTS" ]; then
# read only consumes one line, so treat newlines in multi-line values as separators too
IFS=',' read -r -a MGMT_API_TLS_ALLOWED_CLIENT_LIST <<< "${MGMT_API_TLS_ALLOWED_CLIENTS//$'\n'/,}"
for MGMT_API_TLS_ALLOWED_CLIENT in "${MGMT_API_TLS_ALLOWED_CLIENT_LIST[@]}"; do
if [ ! -z "$MGMT_API_TLS_ALLOWED_CLIENT" ]; then
MGMT_API_TLS_ALLOWED_CLIENT_ARGS+=(--tlsallowedclient "$MGMT_API_TLS_ALLOWED_CLIENT")
fi
done
fi

if [ ! -z "$MGMT_API_PID_FILE" ]; then
MGMT_API_PID_FILE="--pidfile $MGMT_API_PID_FILE"
Expand All @@ -196,8 +207,8 @@ if [ "$1" = 'mgmtapi' ]; then
# use default of 128m heap if env variable not set
: "${MGMT_API_HEAP_SIZE:=128m}"

echo "Running" java ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS
java ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS
echo "Running" java ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS "${MGMT_API_TLS_ALLOWED_CLIENT_ARGS[@]}"
java ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS "${MGMT_API_TLS_ALLOWED_CLIENT_ARGS[@]}"
fi

exec "$@"
15 changes: 13 additions & 2 deletions dse/files/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,17 @@ if [ "$USE_MGMT_API" = "true" ] && [ -d "$MAAC_PATH" ] ; then
MGMT_API_TLS_KEY_FILE="--tlskey $MGMT_API_TLS_KEY_FILE"
MGMT_API_ARGS="$MGMT_API_ARGS $MGMT_API_TLS_KEY_FILE"
fi
# Kept out of MGMT_API_ARGS because identities may contain spaces or glob characters
MGMT_API_TLS_ALLOWED_CLIENT_ARGS=()
if [ ! -z "$MGMT_API_TLS_ALLOWED_CLIENTS" ]; then
# read only consumes one line, so treat newlines in multi-line values as separators too
IFS=',' read -r -a MGMT_API_TLS_ALLOWED_CLIENT_LIST <<< "${MGMT_API_TLS_ALLOWED_CLIENTS//$'\n'/,}"
for MGMT_API_TLS_ALLOWED_CLIENT in "${MGMT_API_TLS_ALLOWED_CLIENT_LIST[@]}"; do
if [ ! -z "$MGMT_API_TLS_ALLOWED_CLIENT" ]; then
MGMT_API_TLS_ALLOWED_CLIENT_ARGS+=(--tlsallowedclient "$MGMT_API_TLS_ALLOWED_CLIENT")
fi
done
fi

if [ ! -z "$MGMT_API_PID_FILE" ]; then
MGMT_API_PID_FILE="--pidfile $MGMT_API_PID_FILE"
Expand Down Expand Up @@ -206,8 +217,8 @@ if [ "$USE_MGMT_API" = "true" ] && [ -d "$MAAC_PATH" ] ; then
echo "/tmp/dse.sock already exists or is already symlinked"
fi

echo "Running" ${JAVA11_JAVA} ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS
${JAVA11_JAVA} ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS
echo "Running" ${JAVA11_JAVA} ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS "${MGMT_API_TLS_ALLOWED_CLIENT_ARGS[@]}"
${JAVA11_JAVA} ${MGMT_API_JAVA_OPTS} -Xms${MGMT_API_HEAP_SIZE} -Xmx${MGMT_API_HEAP_SIZE} -jar "$MGMT_API_JAR" $MGMT_API_ARGS "${MGMT_API_TLS_ALLOWED_CLIENT_ARGS[@]}"
else
echo "Running $@"
exec "$@"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/*
* Copyright DataStax, Inc.
*
* Please see the included license file for details.
*/
package com.datastax.mgmtapi;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableSet;
import com.google.common.net.InetAddresses;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.Socket;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.Attribute;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509ExtendedTrustManager;

// Being signed by the CA is not enough when the CA also issues certificates for other services
public class AllowedClientsTrustManager extends X509ExtendedTrustManager {
private static final int SAN_RFC822_NAME = 1;
private static final int SAN_DNS_NAME = 2;
private static final int SAN_URI = 6;
private static final int SAN_IP_ADDRESS = 7;

private final X509ExtendedTrustManager delegate;
private final Set<String> allowedNames;
// DNS names are case-insensitive (RFC 4343)
private final Set<String> allowedDnsNames;
// The JDK renders IP SANs in a fixed form (IPv6 uncompressed), so compare addresses, not strings
private final Set<InetAddress> allowedIpAddresses;

@VisibleForTesting
AllowedClientsTrustManager(X509ExtendedTrustManager delegate, Collection<String> allowedClients) {
this.delegate = delegate;
this.allowedNames = ImmutableSet.copyOf(allowedClients);
ImmutableSet.Builder<String> dnsNames = ImmutableSet.builder();
ImmutableSet.Builder<InetAddress> ipAddresses = ImmutableSet.builder();
for (String client : allowedClients) {
dnsNames.add(client.toLowerCase(Locale.ROOT));
if (InetAddresses.isInetAddress(client)) {
ipAddresses.add(InetAddresses.forString(client));
}
}
this.allowedDnsNames = dnsNames.build();
this.allowedIpAddresses = ipAddresses.build();
}

public static AllowedClientsTrustManager forCaFile(File caCertFile, List<String> allowedClients)
throws GeneralSecurityException, IOException {
KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
int i = 0;
try (InputStream in = new FileInputStream(caCertFile)) {
for (java.security.cert.Certificate cert :
CertificateFactory.getInstance("X.509").generateCertificates(in)) {
trustStore.setCertificateEntry("ca-" + i++, cert);
}
}
// An empty trust store would reject every client instead of failing at startup like Netty does
if (i == 0) {
throw new CertificateException(caCertFile + " does not contain valid certificates");
}

TrustManagerFactory tmf =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(trustStore);
for (TrustManager tm : tmf.getTrustManagers()) {
if (tm instanceof X509ExtendedTrustManager) {
return new AllowedClientsTrustManager((X509ExtendedTrustManager) tm, allowedClients);
}
}
throw new GeneralSecurityException("No X509ExtendedTrustManager available");
}

@Override
public void checkClientTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
delegate.checkClientTrusted(chain, authType);
checkAllowed(chain);
}

@Override
public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket)
throws CertificateException {
delegate.checkClientTrusted(chain, authType, socket);
checkAllowed(chain);
}

@Override
public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine)
throws CertificateException {
delegate.checkClientTrusted(chain, authType, engine);
checkAllowed(chain);
}

@Override
public void checkServerTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
delegate.checkServerTrusted(chain, authType);
}

@Override
public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket)
throws CertificateException {
delegate.checkServerTrusted(chain, authType, socket);
}

@Override
public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine)
throws CertificateException {
delegate.checkServerTrusted(chain, authType, engine);
}

@Override
public X509Certificate[] getAcceptedIssuers() {
return delegate.getAcceptedIssuers();
}

private void checkAllowed(X509Certificate[] chain) throws CertificateException {
if (chain == null || chain.length == 0) {
throw new CertificateException("No client certificate presented");
}
List<String> identities = new ArrayList<>();
if (isAllowed(chain[0], identities)) {
return;
}
throw new CertificateException(
"Client certificate " + identities + " is not in the list of allowed clients");
}

@VisibleForTesting
boolean isAllowed(X509Certificate cert, List<String> identities)
throws CertificateParsingException {
try {
for (Rdn rdn : new LdapName(cert.getSubjectX500Principal().getName()).getRdns()) {
// Rdn.getType() only exposes the first attribute of a multi-valued RDN such as
// CN=x+SERIALNUMBER=y
Attribute cns = rdn.toAttributes().get("CN");
if (cns == null) {
continue;
}
NamingEnumeration<?> values = cns.getAll();
while (values.hasMore()) {
String cn = values.next().toString();
identities.add(cn);
if (allowedNames.contains(cn)) {
return true;
}
}
}
} catch (NamingException e) {
throw new CertificateParsingException("Unable to parse client certificate subject", e);
}

Collection<List<?>> sans = cert.getSubjectAlternativeNames();
if (sans == null) {
return false;
}
for (List<?> san : sans) {
int type = (Integer) san.get(0);
if (type != SAN_RFC822_NAME
&& type != SAN_DNS_NAME
&& type != SAN_URI
&& type != SAN_IP_ADDRESS) {
continue;
}
String value = (String) san.get(1);
identities.add(value);
if (type == SAN_DNS_NAME) {
if (allowedDnsNames.contains(value.toLowerCase(Locale.ROOT))) {
return true;
}
} else if (type == SAN_IP_ADDRESS) {
if (InetAddresses.isInetAddress(value)
&& allowedIpAddresses.contains(InetAddresses.forString(value))) {
return true;
}
} else if (allowedNames.contains(value)) {
return true;
}
}
return false;
}
}
Loading
Loading