mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 08:13:34 +00:00
feat(cassandra): support HOCON Kerberos and Astra natively
This commit is contained in:
@@ -8,8 +8,14 @@ multi-session JSON-RPC protocol without a JVM.
|
||||
- Native protocol versions: v3-v5
|
||||
- Declared server range: Apache Cassandra 2.1+
|
||||
- Live validation: 2.2.19, 3.11.19, 4.1.10, and 5.0.6
|
||||
- Authentication: username/password
|
||||
- Kerberos live validation: Cassandra 4.1.10 with password, keytab, FILE ccache,
|
||||
JAAS discovery, and HOCON `configfile`
|
||||
- Astra validation: secure-connect bundle parsing and transport configuration;
|
||||
live Astra credentials were not available
|
||||
- Authentication: username/password and Kerberos/GSSAPI
|
||||
- TLS: CA verification, optional client certificate/key, hostname verification
|
||||
- Cloud: DataStax Astra secure connect bundles
|
||||
- Configuration: Java Driver 4 HOCON `configfile` mapping plus native extensions
|
||||
- Metadata: keyspaces, tables, columns, indexes, CQL table DDL, completion search
|
||||
- Queries: legacy string result values, paging, cancellation, logged and unlogged batches
|
||||
|
||||
@@ -33,15 +39,87 @@ syntax.
|
||||
| `sslenginefactory` | the standard `DefaultSslEngineFactory` maps to native TLS |
|
||||
| `hostnameverification` | TLS hostname verification; enabled by default |
|
||||
| `user`, `password` | password authentication |
|
||||
| `configfile` | Java Driver 4 HOCON configuration; overrides URL options except contact points and keyspace |
|
||||
| `usekrb5` | Kerberos/GSSAPI authentication using password, keytab, or FILE credential cache |
|
||||
| `secureconnectbundle` | DataStax Astra secure connect bundle; contact points and manual TLS options are ignored |
|
||||
| `requesttimeout`, `connecttimeout` | request and connection deadlines |
|
||||
| `tcpnodelay`, `keepalive` | native TCP socket options |
|
||||
| `compliancemode` | accepted; JDBC-only `java.sql` behavior is not applicable to JSON-RPC |
|
||||
|
||||
Java implementation hooks do not have a safe native equivalent. The Agent
|
||||
returns a targeted connection error for `configfile`, `usekrb5=true`,
|
||||
`secureconnectbundle`, custom `sslenginefactory` classes, and custom policy
|
||||
classes. Translate Java HOCON settings to the supported URL parameters before
|
||||
migrating a connection.
|
||||
The Agent rejects custom Java implementation classes because they cannot be
|
||||
loaded by a native binary. This includes custom authentication, SSL, retry,
|
||||
reconnection, and load-balancing classes. Java JKS/PKCS12 truststores and
|
||||
keystores are not read directly; use the native PEM paths described below.
|
||||
|
||||
## Java Driver HOCON configuration
|
||||
|
||||
`configfile` reads Java Driver 4 HOCON files and preserves the JDBC wrapper's
|
||||
precedence: the file overrides URL options except contact points and keyspace.
|
||||
A missing file is ignored for compatibility with the JDBC wrapper.
|
||||
|
||||
Mapped Java Driver paths include:
|
||||
|
||||
- `basic.request.timeout`, `consistency`, `serial-consistency`, and `page-size`
|
||||
- `basic.load-balancing-policy.class` and `local-datacenter`
|
||||
- `basic.cloud.secure-connect-bundle`
|
||||
- `advanced.connection.connect-timeout` and `pool.local.size`
|
||||
- `advanced.socket.tcp-no-delay` and `keep-alive`
|
||||
- `advanced.protocol.version`, retry policy, and reconnection policy
|
||||
- `advanced.auth-provider` plaintext and Instaclustr Kerberos options
|
||||
- `advanced.ssl-engine-factory` default TLS and hostname validation
|
||||
|
||||
Native-only settings can be placed under `dbx.cassandra`:
|
||||
|
||||
```hocon
|
||||
dbx.cassandra {
|
||||
tls {
|
||||
enabled = true
|
||||
ca-cert-path = "/path/to/ca.pem"
|
||||
client-cert-path = "/path/to/client.pem"
|
||||
client-key-path = "/path/to/client-key.pem"
|
||||
hostname-verification = true
|
||||
}
|
||||
kerberos {
|
||||
enabled = true
|
||||
config = "/etc/krb5.conf"
|
||||
jaas-config = "/path/to/jaas.conf"
|
||||
principal = "alice@EXAMPLE.COM"
|
||||
keytab = "/path/to/alice.keytab"
|
||||
service-name = "cassandra"
|
||||
server-name = "node1.example.com"
|
||||
authorization-id = "assumed_role"
|
||||
qop = "auth"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Kerberos
|
||||
|
||||
`usekrb5=true` implements the same GSSAPI flow used by the former Instaclustr
|
||||
Java auth provider. The service principal defaults to
|
||||
`cassandra/<canonical-node-hostname>`. Set `kerberosservername` when reverse DNS
|
||||
does not resolve to the service-principal hostname.
|
||||
|
||||
Credential discovery order is:
|
||||
|
||||
1. Explicit JAAS `CassandraJavaClient` cache/keytab selection
|
||||
2. Explicit `kerberosccache` or `kerberoskeytab`
|
||||
3. Explicit principal and password
|
||||
4. `KRB5CCNAME`, then `KRB5_CLIENT_KTNAME`/`KRB5_KTNAME`
|
||||
|
||||
The Agent also reads `java.security.auth.login.config` and
|
||||
`java.security.krb5.conf` from `JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, or
|
||||
`JDK_JAVA_OPTIONS`. Only FILE credential caches are supported. SASL QOP `auth`
|
||||
is supported; `auth-int` and `auth-conf` are rejected because they require
|
||||
wrapping Cassandra traffic after authentication.
|
||||
|
||||
## Astra secure connect bundles
|
||||
|
||||
Set `secureconnectbundle` to a local Astra bundle ZIP and provide its database
|
||||
credentials with `user` and `password`. A normal Cassandra host is not required.
|
||||
Kerberos cannot be combined with a secure connect bundle. Manual TLS settings
|
||||
are ignored because the bundle supplies its own CA, client certificate, key,
|
||||
SNI endpoint, and metadata service.
|
||||
|
||||
## Integration test
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
)
|
||||
|
||||
func TestSecureConnectBundleBuildsAstraClusterWithoutHost(t *testing.T) {
|
||||
bundlePath := writeTestSecureConnectBundle(t)
|
||||
config, err := parseCassandraConfig(connectParams{
|
||||
Username: "token",
|
||||
Password: "astra-token",
|
||||
URLParams: url.Values{
|
||||
"secureconnectbundle": []string{bundlePath},
|
||||
"requesttimeout": []string{"9s"},
|
||||
"connecttimeout": []string{"7s"},
|
||||
}.Encode(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.hosts) != 0 || config.secureConnectBundle != bundlePath {
|
||||
t.Fatalf("unexpected Astra config: %#v", config)
|
||||
}
|
||||
|
||||
cluster, err := config.clusterConfig("app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cluster.HostDialer == nil {
|
||||
t.Fatal("Astra cluster must use the secure-connect HostDialer")
|
||||
}
|
||||
if reflect.DeepEqual(cluster.Hosts, config.hosts) || len(cluster.Hosts) != 3 {
|
||||
t.Fatalf("Astra cluster must use dialer placeholder hosts: %#v", cluster.Hosts)
|
||||
}
|
||||
credentials, ok := cluster.Authenticator.(*gocql.PasswordAuthenticator)
|
||||
if !ok || credentials.Username != "token" || credentials.Password != "astra-token" {
|
||||
t.Fatalf("unexpected Astra authenticator: %#v", cluster.Authenticator)
|
||||
}
|
||||
if cluster.Keyspace != "app" || cluster.Timeout != 9*time.Second || cluster.ConnectTimeout != 7*time.Second {
|
||||
t.Fatalf("unexpected Astra cluster options: %#v", cluster)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureConnectBundleSupportsHOCONConfiguration(t *testing.T) {
|
||||
bundlePath := writeTestSecureConnectBundle(t)
|
||||
configPath := writeTestFile(t, "astra.conf", `
|
||||
datastax-java-driver {
|
||||
basic.cloud.secure-connect-bundle = "`+bundlePath+`"
|
||||
advanced.auth-provider {
|
||||
class = PlainTextAuthProvider
|
||||
username = token
|
||||
password = astra-token
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
config, err := parseCassandraConfig(connectParams{
|
||||
URLParams: url.Values{"configfile": []string{configPath}}.Encode(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.secureConnectBundle != bundlePath || config.username != "token" || config.password != "astra-token" {
|
||||
t.Fatalf("unexpected HOCON Astra config: %#v", config)
|
||||
}
|
||||
if _, err := config.clusterConfig(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureConnectBundleValidatesCredentialsAndAuthMode(t *testing.T) {
|
||||
bundlePath := writeTestSecureConnectBundle(t)
|
||||
|
||||
if _, err := parseCassandraConfig(connectParams{
|
||||
URLParams: url.Values{"secureconnectbundle": []string{bundlePath}}.Encode(),
|
||||
}); err == nil {
|
||||
t.Fatal("expected Astra credential validation error")
|
||||
}
|
||||
|
||||
if _, err := parseCassandraConfig(connectParams{
|
||||
Username: "token",
|
||||
Password: "astra-token",
|
||||
URLParams: url.Values{
|
||||
"secureconnectbundle": []string{bundlePath},
|
||||
"usekrb5": []string{"true"},
|
||||
}.Encode(),
|
||||
}); err == nil {
|
||||
t.Fatal("expected Astra and Kerberos conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestSecureConnectBundle(t *testing.T) string {
|
||||
t.Helper()
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "dbx-astra-test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
privateKeyDER, err := x509.MarshalECPrivateKey(privateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificatePEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificateDER})
|
||||
privateKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privateKeyDER})
|
||||
configJSON, err := json.Marshal(map[string]any{"host": "astra.example.com", "port": 29042})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bundlePath := filepath.Join(t.TempDir(), "secure-connect.zip")
|
||||
bundle, err := os.Create(bundlePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := zip.NewWriter(bundle)
|
||||
for name, contents := range map[string][]byte{
|
||||
"config.json": configJSON,
|
||||
"ca.crt": certificatePEM,
|
||||
"cert": certificatePEM,
|
||||
"key": privateKeyPEM,
|
||||
} {
|
||||
entry, createErr := archive.Create(name)
|
||||
if createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
if _, writeErr := entry.Write(contents); writeErr != nil {
|
||||
t.Fatal(writeErr)
|
||||
}
|
||||
}
|
||||
if err := archive.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bundle.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bundlePath
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
gocqlastra "github.com/datastax/gocql-astra/v2"
|
||||
)
|
||||
|
||||
type cassandraConfig struct {
|
||||
@@ -41,6 +42,9 @@ type cassandraConfig struct {
|
||||
reconnectionMaxDelay time.Duration
|
||||
loadBalancingPolicy string
|
||||
disableInitialHostLookup bool
|
||||
configFile string
|
||||
secureConnectBundle string
|
||||
kerberos kerberosConfig
|
||||
}
|
||||
|
||||
func parseCassandraConfig(cp connectParams) (cassandraConfig, error) {
|
||||
@@ -62,6 +66,7 @@ func parseCassandraConfig(cp connectParams) (cassandraConfig, error) {
|
||||
retryCount: 3,
|
||||
reconnectionBaseDelay: time.Second,
|
||||
reconnectionMaxDelay: 60 * time.Second,
|
||||
kerberos: defaultKerberosConfig(),
|
||||
}
|
||||
if cp.Port > 0 {
|
||||
config.port = cp.Port
|
||||
@@ -76,10 +81,6 @@ func parseCassandraConfig(cp connectParams) (cassandraConfig, error) {
|
||||
if len(config.hosts) == 0 {
|
||||
config.hosts = splitHosts(cp.Host)
|
||||
}
|
||||
if len(config.hosts) == 0 {
|
||||
return cassandraConfig{}, fmt.Errorf("Cassandra host is required")
|
||||
}
|
||||
|
||||
urlParams, err := parseURLParams(cp.URLParams)
|
||||
if err != nil {
|
||||
return cassandraConfig{}, err
|
||||
@@ -90,7 +91,18 @@ func parseCassandraConfig(cp connectParams) (cassandraConfig, error) {
|
||||
if err := applyCassandraURLParams(&config, params); err != nil {
|
||||
return cassandraConfig{}, err
|
||||
}
|
||||
if !config.disableInitialHostLookup && allLoopbackHosts(config.hosts) {
|
||||
if config.configFile != "" {
|
||||
if err := applyCassandraConfigFile(&config, config.configFile); err != nil {
|
||||
return cassandraConfig{}, err
|
||||
}
|
||||
}
|
||||
if err := config.finalize(); err != nil {
|
||||
return cassandraConfig{}, err
|
||||
}
|
||||
if len(config.hosts) == 0 && config.secureConnectBundle == "" {
|
||||
return cassandraConfig{}, fmt.Errorf("Cassandra host is required")
|
||||
}
|
||||
if len(config.hosts) > 0 && !config.disableInitialHostLookup && allLoopbackHosts(config.hosts) {
|
||||
config.disableInitialHostLookup = true
|
||||
}
|
||||
return config, nil
|
||||
@@ -276,17 +288,53 @@ func applyCassandraURLParams(config *cassandraConfig, params url.Values) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid usekrb5 option: %w", err)
|
||||
}
|
||||
if enabled {
|
||||
return fmt.Errorf("Cassandra Kerberos authentication is not supported by the native agent")
|
||||
}
|
||||
config.kerberos.enabled = enabled
|
||||
case "secureconnectbundle":
|
||||
if value != "" {
|
||||
return fmt.Errorf("Cassandra secure connect bundles are not supported by the native agent")
|
||||
}
|
||||
config.secureConnectBundle = value
|
||||
case "configfile":
|
||||
if value != "" {
|
||||
return fmt.Errorf("Cassandra Java driver configfile is not supported; translate it to native URL parameters")
|
||||
config.configFile = value
|
||||
case "kerberosconfig", "kerberosconfigpath", "krb5config", "krb5conf":
|
||||
config.kerberos.configPath = value
|
||||
case "jaasconfig", "jaasconfigpath":
|
||||
config.kerberos.jaasConfigPath = value
|
||||
case "kerberosprincipal", "krb5principal":
|
||||
config.kerberos.principal = value
|
||||
case "kerberosrealm", "krb5realm":
|
||||
config.kerberos.realm = value
|
||||
case "kerberoskeytab", "keytab":
|
||||
config.kerberos.keytabPath = value
|
||||
case "kerberosccache", "kerberosticketcache", "ccache", "ticketcache":
|
||||
config.kerberos.ccachePath = value
|
||||
case "kerberospassword":
|
||||
config.kerberos.password = value
|
||||
case "kerberosservice", "kerberosservicename", "saslprotocol":
|
||||
config.kerberos.serviceName = value
|
||||
case "kerberosservername", "saslservername":
|
||||
config.kerberos.serverName = value
|
||||
case "kerberosauthorizationid", "authorizationid":
|
||||
config.kerberos.authorizationID = value
|
||||
case "kerberosqop", "saslqop":
|
||||
config.kerberos.qop = value
|
||||
case "kerberosdisablepafxfast", "disablepafxfast":
|
||||
disabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid disablepafxfast option: %w", err)
|
||||
}
|
||||
config.kerberos.disablePAFXFAST = disabled
|
||||
case "kerberosusekeytab", "usekeytab":
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid usekeytab option: %w", err)
|
||||
}
|
||||
config.kerberos.useKeytab = enabled
|
||||
config.kerberos.useKeytabSet = true
|
||||
case "kerberosuseticketcache", "useticketcache":
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid useticketcache option: %w", err)
|
||||
}
|
||||
config.kerberos.useTicketCache = enabled
|
||||
config.kerberos.useTicketCacheSet = true
|
||||
case "compliancemode":
|
||||
// JDBC compliance modes only alter java.sql behavior. The native DBX
|
||||
// JSON-RPC contract already defines statement and transaction behavior.
|
||||
@@ -298,21 +346,35 @@ func applyCassandraURLParams(config *cassandraConfig, params url.Values) error {
|
||||
}
|
||||
|
||||
func (config cassandraConfig) clusterConfig(keyspace string) (*gocql.ClusterConfig, error) {
|
||||
cluster := gocql.NewCluster(config.hosts...)
|
||||
cluster.Port = config.port
|
||||
var cluster *gocql.ClusterConfig
|
||||
var err error
|
||||
if config.secureConnectBundle != "" {
|
||||
cluster, err = gocqlastra.NewClusterFromBundle(
|
||||
config.secureConnectBundle,
|
||||
config.username,
|
||||
config.password,
|
||||
config.connectTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Cassandra secure connect bundle: %w", err)
|
||||
}
|
||||
} else {
|
||||
cluster = gocql.NewCluster(config.hosts...)
|
||||
cluster.Port = config.port
|
||||
cluster.Dialer = cassandraDialer{
|
||||
timeout: config.connectTimeout,
|
||||
keepAlive: config.keepAlive,
|
||||
tcpNoDelay: config.tcpNoDelay,
|
||||
}
|
||||
cluster.DisableInitialHostLookup = config.disableInitialHostLookup
|
||||
cluster.IgnorePeerAddr = config.disableInitialHostLookup
|
||||
}
|
||||
cluster.Keyspace = strings.TrimSpace(keyspace)
|
||||
cluster.Timeout = config.requestTimeout
|
||||
cluster.ConnectTimeout = config.connectTimeout
|
||||
cluster.WriteTimeout = config.requestTimeout
|
||||
cluster.NumConns = config.numConnections
|
||||
cluster.PageSize = config.pageSize
|
||||
cluster.Dialer = cassandraDialer{
|
||||
timeout: config.connectTimeout,
|
||||
keepAlive: config.keepAlive,
|
||||
tcpNoDelay: config.tcpNoDelay,
|
||||
}
|
||||
cluster.DisableInitialHostLookup = config.disableInitialHostLookup
|
||||
cluster.IgnorePeerAddr = config.disableInitialHostLookup
|
||||
if config.protocolVersion != 0 {
|
||||
cluster.ProtoVersion = config.protocolVersion
|
||||
}
|
||||
@@ -333,10 +395,17 @@ func (config cassandraConfig) clusterConfig(keyspace string) (*gocql.ClusterConf
|
||||
}
|
||||
cluster.SerialConsistency = consistency
|
||||
}
|
||||
if config.username != "" {
|
||||
if config.kerberos.enabled {
|
||||
authProvider, err := newKerberosAuthProvider(config.kerberos, config.username, config.password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cluster.Authenticator = nil
|
||||
cluster.AuthProvider = authProvider
|
||||
} else if config.secureConnectBundle == "" && config.username != "" {
|
||||
cluster.Authenticator = gocql.PasswordAuthenticator{Username: config.username, Password: config.password}
|
||||
}
|
||||
if config.ssl {
|
||||
if config.secureConnectBundle == "" && config.ssl {
|
||||
cluster.SslOpts = &gocql.SslOptions{
|
||||
CaPath: config.caCertPath,
|
||||
CertPath: config.clientCertPath,
|
||||
@@ -356,6 +425,30 @@ func (config cassandraConfig) clusterConfig(keyspace string) (*gocql.ClusterConf
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
func (config *cassandraConfig) finalize() error {
|
||||
var err error
|
||||
config.configFile, err = normalizeLocalFilePath(config.configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Cassandra configfile: %w", err)
|
||||
}
|
||||
config.secureConnectBundle, err = normalizeLocalFilePath(config.secureConnectBundle)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Cassandra secureconnectbundle: %w", err)
|
||||
}
|
||||
if config.secureConnectBundle != "" && config.kerberos.enabled {
|
||||
return fmt.Errorf("Cassandra secure connect bundles cannot be combined with Kerberos authentication")
|
||||
}
|
||||
if config.secureConnectBundle != "" && (config.username == "" || config.password == "") {
|
||||
return fmt.Errorf("Cassandra secure connect bundles require username and password credentials")
|
||||
}
|
||||
if config.kerberos.enabled {
|
||||
if err := config.kerberos.finalize(config.username, config.password); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitHosts(raw string) []string {
|
||||
raw = strings.ReplaceAll(raw, "--", ",")
|
||||
parts := strings.FieldsFunc(raw, func(char rune) bool { return char == ',' || char == ';' })
|
||||
@@ -422,7 +515,7 @@ func normalizeRetryPolicy(value string) (string, error) {
|
||||
func normalizeLoadBalancingPolicy(value string) (string, error) {
|
||||
name := strings.ToLower(simpleClassName(value))
|
||||
switch name {
|
||||
case "", "dcinferringloadbalancingpolicy", "defaultloadbalancingpolicy":
|
||||
case "", "basicloadbalancingpolicy", "dcinferringloadbalancingpolicy", "defaultloadbalancingpolicy":
|
||||
return "default", nil
|
||||
case "roundrobinpolicy":
|
||||
return "round_robin", nil
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
"github.com/gurkankaymak/hocon"
|
||||
)
|
||||
|
||||
const javaDriverConfigPrefix = "datastax-java-driver."
|
||||
|
||||
func applyCassandraConfigFile(config *cassandraConfig, rawPath string) error {
|
||||
path, err := normalizeLocalFilePath(rawPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Cassandra configfile: %w", err)
|
||||
}
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read Cassandra configfile %s: %w", path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("Cassandra configfile is not a regular file: %s", path)
|
||||
}
|
||||
parsed, err := hocon.ParseResource(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse Cassandra configfile %s: %w", path, err)
|
||||
}
|
||||
config.configFile = path
|
||||
if err := applyJavaDriverHOCON(config, parsed); err != nil {
|
||||
return fmt.Errorf("apply Cassandra configfile %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyJavaDriverHOCON(config *cassandraConfig, parsed *hocon.Config) error {
|
||||
if value, ok, err := hoconDuration(parsed, javaDriverConfigPrefix+"basic.request.timeout"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.requestTimeout = value
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"basic.request.consistency"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
if _, err := gocql.ParseConsistencyWrapper(value); err != nil {
|
||||
return err
|
||||
}
|
||||
config.consistency = value
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"basic.request.serial-consistency"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
consistency, err := gocql.ParseConsistencyWrapper(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if consistency != gocql.Serial && consistency != gocql.LocalSerial {
|
||||
return fmt.Errorf("serial consistency must be SERIAL or LOCAL_SERIAL")
|
||||
}
|
||||
config.serialConsistency = value
|
||||
}
|
||||
if value, ok, err := hoconInt(parsed, javaDriverConfigPrefix+"basic.request.page-size"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
if value < 1 {
|
||||
return fmt.Errorf("page size must be positive")
|
||||
}
|
||||
config.pageSize = value
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"basic.load-balancing-policy.local-datacenter"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.localDatacenter = value
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"basic.load-balancing-policy.class"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
policy, err := normalizeLoadBalancingPolicy(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.loadBalancingPolicy = policy
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"basic.cloud.secure-connect-bundle"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.secureConnectBundle = value
|
||||
}
|
||||
if value, ok, err := hoconDuration(parsed, javaDriverConfigPrefix+"advanced.connection.connect-timeout"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.connectTimeout = value
|
||||
}
|
||||
if value, ok, err := hoconInt(parsed, javaDriverConfigPrefix+"advanced.connection.pool.local.size"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
if value < 1 || value > 32 {
|
||||
return fmt.Errorf("connection pool local size must be between 1 and 32")
|
||||
}
|
||||
config.numConnections = value
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, javaDriverConfigPrefix+"advanced.socket.tcp-no-delay"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.tcpNoDelay = value
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, javaDriverConfigPrefix+"advanced.socket.keep-alive"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.keepAlive = value
|
||||
}
|
||||
if value, ok, err := hoconProtocolVersion(parsed, javaDriverConfigPrefix+"advanced.protocol.version"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.protocolVersion = value
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"advanced.retry-policy.class"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
policy, err := normalizeRetryPolicy(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.retryPolicy = policy
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"advanced.reconnection-policy.class"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
policy, baseDelay, maxDelay, err := parseReconnectionPolicy(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.reconnectionPolicy = policy
|
||||
config.reconnectionBaseDelay = baseDelay
|
||||
config.reconnectionMaxDelay = maxDelay
|
||||
}
|
||||
if value, ok, err := hoconDuration(parsed, javaDriverConfigPrefix+"advanced.reconnection-policy.base-delay"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.reconnectionBaseDelay = value
|
||||
}
|
||||
if value, ok, err := hoconDuration(parsed, javaDriverConfigPrefix+"advanced.reconnection-policy.max-delay"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.reconnectionMaxDelay = value
|
||||
}
|
||||
if err := applyHOCONAuthentication(config, parsed); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyHOCONSSL(config, parsed); err != nil {
|
||||
return err
|
||||
}
|
||||
return applyNativeHOCON(config, parsed)
|
||||
}
|
||||
|
||||
func applyHOCONAuthentication(config *cassandraConfig, parsed *hocon.Config) error {
|
||||
prefix := javaDriverConfigPrefix + "advanced.auth-provider."
|
||||
if value, ok, err := hoconString(parsed, prefix+"class"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
switch strings.ToLower(simpleClassName(value)) {
|
||||
case "plaintextauthprovider", "dseplaintextauthprovider":
|
||||
config.kerberos.enabled = false
|
||||
case "kerberosauthprovider", "programmatickerberosauthprovider", "dsegssapiauthprovider":
|
||||
config.kerberos.enabled = true
|
||||
default:
|
||||
return fmt.Errorf("unsupported Cassandra auth provider class: %s", value)
|
||||
}
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, prefix+"username"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.username = value
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, prefix+"password"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.password = value
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, prefix+"authorization-id"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.authorizationID = value
|
||||
}
|
||||
for _, path := range []string{prefix + "sasl-protocol", prefix + "service"} {
|
||||
if value, ok, err := hoconString(parsed, path); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.serviceName = value
|
||||
}
|
||||
}
|
||||
if value, ok, err := hoconStringMap(parsed, prefix+"sasl-properties"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
for key, property := range value {
|
||||
if strings.EqualFold(key, "javax.security.sasl.qop") {
|
||||
config.kerberos.qop = property
|
||||
}
|
||||
}
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, prefix+"server-name-resolver"); err != nil {
|
||||
return err
|
||||
} else if ok && value != "" {
|
||||
return fmt.Errorf("custom Java Kerberos server-name-resolver is not supported; use dbx.cassandra.kerberos.server-name")
|
||||
}
|
||||
loginPrefix := prefix + "login-configuration."
|
||||
if value, ok, err := hoconString(parsed, loginPrefix+"principal"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.principal = value
|
||||
}
|
||||
if value, ok, err := firstHOCONString(parsed, loginPrefix+"keyTab", loginPrefix+"keytab"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.keytabPath = value
|
||||
}
|
||||
if value, ok, err := firstHOCONString(parsed, loginPrefix+"ticketCache", loginPrefix+"ticket-cache"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.ccachePath = value
|
||||
}
|
||||
if value, ok, err := firstHOCONBool(parsed, loginPrefix+"useKeyTab", loginPrefix+"use-keytab"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.useKeytab = value
|
||||
config.kerberos.useKeytabSet = true
|
||||
}
|
||||
if value, ok, err := firstHOCONBool(parsed, loginPrefix+"useTicketCache", loginPrefix+"use-ticket-cache"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.useTicketCache = value
|
||||
config.kerberos.useTicketCacheSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyHOCONSSL(config *cassandraConfig, parsed *hocon.Config) error {
|
||||
prefix := javaDriverConfigPrefix + "advanced.ssl-engine-factory."
|
||||
if value, ok, err := hoconString(parsed, prefix+"class"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
if !strings.EqualFold(simpleClassName(value), "DefaultSslEngineFactory") {
|
||||
return fmt.Errorf("unsupported Cassandra SSL engine factory class: %s", value)
|
||||
}
|
||||
config.ssl = true
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, prefix+"hostname-validation"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.hostVerification = value
|
||||
config.ssl = true
|
||||
}
|
||||
for _, path := range []string{prefix + "truststore-path", prefix + "keystore-path"} {
|
||||
if value, ok, err := hoconString(parsed, path); err != nil {
|
||||
return err
|
||||
} else if ok && value != "" {
|
||||
return fmt.Errorf("Java truststore and keystore files are not supported; use dbx.cassandra.tls PEM paths")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyNativeHOCON(config *cassandraConfig, parsed *hocon.Config) error {
|
||||
prefix := "dbx.cassandra."
|
||||
stringMappings := []struct {
|
||||
path string
|
||||
target *string
|
||||
}{
|
||||
{"tls.ca-cert-path", &config.caCertPath},
|
||||
{"tls.client-cert-path", &config.clientCertPath},
|
||||
{"tls.client-key-path", &config.clientKeyPath},
|
||||
{"kerberos.config", &config.kerberos.configPath},
|
||||
{"kerberos.jaas-config", &config.kerberos.jaasConfigPath},
|
||||
{"kerberos.principal", &config.kerberos.principal},
|
||||
{"kerberos.realm", &config.kerberos.realm},
|
||||
{"kerberos.keytab", &config.kerberos.keytabPath},
|
||||
{"kerberos.ccache", &config.kerberos.ccachePath},
|
||||
{"kerberos.password", &config.kerberos.password},
|
||||
{"kerberos.service-name", &config.kerberos.serviceName},
|
||||
{"kerberos.server-name", &config.kerberos.serverName},
|
||||
{"kerberos.authorization-id", &config.kerberos.authorizationID},
|
||||
{"kerberos.qop", &config.kerberos.qop},
|
||||
}
|
||||
for _, mapping := range stringMappings {
|
||||
if value, ok, err := hoconString(parsed, prefix+mapping.path); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
*mapping.target = value
|
||||
}
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, prefix+"tls.enabled"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.ssl = value
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, prefix+"tls.hostname-verification"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.hostVerification = value
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, prefix+"kerberos.enabled"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.enabled = value
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, prefix+"kerberos.disable-pafxfast"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.disablePAFXFAST = value
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, prefix+"kerberos.use-keytab"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.useKeytab = value
|
||||
config.kerberos.useKeytabSet = true
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, prefix+"kerberos.use-ticket-cache"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.useTicketCache = value
|
||||
config.kerberos.useTicketCacheSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeLocalFilePath(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.Contains(value, "://") || strings.HasPrefix(strings.ToLower(value), "file:") {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsed.Scheme != "file" {
|
||||
return "", fmt.Errorf("unsupported file URI scheme: %s", parsed.Scheme)
|
||||
}
|
||||
if parsed.Host != "" && !strings.EqualFold(parsed.Host, "localhost") {
|
||||
return "", fmt.Errorf("remote file URI hosts are not supported: %s", parsed.Host)
|
||||
}
|
||||
value, err = url.PathUnescape(parsed.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if runtime.GOOS == "windows" && len(value) >= 3 && value[0] == '/' && value[2] == ':' {
|
||||
value = value[1:]
|
||||
}
|
||||
}
|
||||
return filepath.Clean(filepath.FromSlash(value)), nil
|
||||
}
|
||||
|
||||
func hoconString(config *hocon.Config, path string) (string, bool, error) {
|
||||
if config.Get(path) == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
value, err := config.GetStringE(path)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("invalid %s: %w", path, err)
|
||||
}
|
||||
return strings.TrimSpace(value), true, nil
|
||||
}
|
||||
|
||||
func firstHOCONString(config *hocon.Config, paths ...string) (string, bool, error) {
|
||||
for _, path := range paths {
|
||||
value, ok, err := hoconString(config, path)
|
||||
if err != nil || ok {
|
||||
return value, ok, err
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func hoconStringMap(config *hocon.Config, path string) (map[string]string, bool, error) {
|
||||
if config.Get(path) == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
value, err := config.GetStringMapStringE(path)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("invalid %s: %w", path, err)
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
func hoconDuration(config *hocon.Config, path string) (time.Duration, bool, error) {
|
||||
if config.Get(path) == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
value, err := config.GetDurationE(path)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("invalid %s: %w", path, err)
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
func hoconInt(config *hocon.Config, path string) (int, bool, error) {
|
||||
if config.Get(path) == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
value, err := config.GetIntE(path)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("invalid %s: %w", path, err)
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
func hoconBool(config *hocon.Config, path string) (bool, bool, error) {
|
||||
value := config.Get(path)
|
||||
if value == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case hocon.Boolean:
|
||||
return bool(typed), true, nil
|
||||
case hocon.String:
|
||||
parsed, err := strconv.ParseBool(string(typed))
|
||||
if err != nil {
|
||||
return false, false, fmt.Errorf("invalid %s: %w", path, err)
|
||||
}
|
||||
return parsed, true, nil
|
||||
default:
|
||||
return false, false, fmt.Errorf("invalid %s: expected boolean", path)
|
||||
}
|
||||
}
|
||||
|
||||
func firstHOCONBool(config *hocon.Config, paths ...string) (bool, bool, error) {
|
||||
for _, path := range paths {
|
||||
value, ok, err := hoconBool(config, path)
|
||||
if err != nil || ok {
|
||||
return value, ok, err
|
||||
}
|
||||
}
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
func hoconProtocolVersion(config *hocon.Config, path string) (int, bool, error) {
|
||||
value := config.Get(path)
|
||||
if value == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
var raw string
|
||||
switch typed := value.(type) {
|
||||
case hocon.Int:
|
||||
raw = strconv.Itoa(int(typed))
|
||||
case hocon.String:
|
||||
raw = string(typed)
|
||||
default:
|
||||
return 0, false, fmt.Errorf("invalid %s: expected V3, V4, or V5", path)
|
||||
}
|
||||
raw = strings.TrimPrefix(strings.ToUpper(strings.TrimSpace(raw)), "V")
|
||||
version, err := strconv.Atoi(raw)
|
||||
if err != nil || version < 3 || version > 5 {
|
||||
return 0, false, fmt.Errorf("invalid %s: expected V3, V4, or V5", path)
|
||||
}
|
||||
return version, true, nil
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gurkankaymak/hocon"
|
||||
)
|
||||
|
||||
func TestCassandraConfigFileOverridesURLExceptEndpoint(t *testing.T) {
|
||||
configPath := writeTestFile(t, "application.conf", `
|
||||
datastax-java-driver {
|
||||
basic {
|
||||
contact-points = ["ignored.example.com:9042"]
|
||||
session-keyspace = ignored_keyspace
|
||||
request {
|
||||
timeout = 2 seconds
|
||||
consistency = LOCAL_ONE
|
||||
serial-consistency = LOCAL_SERIAL
|
||||
page-size = 321
|
||||
}
|
||||
load-balancing-policy {
|
||||
class = BasicLoadBalancingPolicy
|
||||
local-datacenter = dc-config
|
||||
}
|
||||
}
|
||||
advanced {
|
||||
connection {
|
||||
connect-timeout = 3 seconds
|
||||
pool.local.size = 4
|
||||
}
|
||||
socket {
|
||||
tcp-no-delay = false
|
||||
keep-alive = true
|
||||
}
|
||||
protocol.version = V4
|
||||
retry-policy.class = FallthroughRetryPolicy
|
||||
reconnection-policy {
|
||||
class = ExponentialReconnectionPolicy
|
||||
base-delay = 4 seconds
|
||||
max-delay = 20 seconds
|
||||
}
|
||||
auth-provider {
|
||||
class = PlainTextAuthProvider
|
||||
username = file-user
|
||||
password = file-password
|
||||
}
|
||||
ssl-engine-factory {
|
||||
class = DefaultSslEngineFactory
|
||||
hostname-validation = false
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
config, err := parseCassandraConfig(connectParams{
|
||||
Host: "url.example.com",
|
||||
Database: "url_keyspace",
|
||||
Username: "url-user",
|
||||
Password: "url-password",
|
||||
URLParams: url.Values{
|
||||
"configfile": []string{configPath},
|
||||
"requesttimeout": []string{"30s"},
|
||||
"connecttimeout": []string{"31s"},
|
||||
"consistency": []string{"QUORUM"},
|
||||
"serialconsistency": []string{"SERIAL"},
|
||||
"fetchsize": []string{"999"},
|
||||
"localdatacenter": []string{"dc-url"},
|
||||
"protocolversion": []string{"5"},
|
||||
"numconns": []string{"2"},
|
||||
"tcpnodelay": []string{"true"},
|
||||
"keepalive": []string{"false"},
|
||||
"retry": []string{"DefaultRetryPolicy"},
|
||||
"reconnection": []string{"ConstantReconnectionPolicy((long)1)"},
|
||||
"user": []string{"query-user"},
|
||||
"password": []string{"query-password"},
|
||||
}.Encode(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(config.hosts, []string{"url.example.com"}) || config.keyspace != "url_keyspace" {
|
||||
t.Fatalf("configfile must not replace endpoint or keyspace: %#v", config)
|
||||
}
|
||||
if config.username != "file-user" || config.password != "file-password" {
|
||||
t.Fatalf("configfile credentials did not override URL values: %#v", config)
|
||||
}
|
||||
if config.requestTimeout != 2*time.Second || config.connectTimeout != 3*time.Second {
|
||||
t.Fatalf("unexpected configfile timeouts: request=%s connect=%s", config.requestTimeout, config.connectTimeout)
|
||||
}
|
||||
if config.consistency != "LOCAL_ONE" || config.serialConsistency != "LOCAL_SERIAL" || config.pageSize != 321 {
|
||||
t.Fatalf("unexpected request config: %#v", config)
|
||||
}
|
||||
if config.localDatacenter != "dc-config" || config.loadBalancingPolicy != "default" || config.protocolVersion != 4 {
|
||||
t.Fatalf("unexpected topology/protocol config: %#v", config)
|
||||
}
|
||||
if config.numConnections != 4 || config.tcpNoDelay || !config.keepAlive {
|
||||
t.Fatalf("unexpected connection/socket config: %#v", config)
|
||||
}
|
||||
if config.retryPolicy != "fallthrough" || config.reconnectionPolicy != "exponential" {
|
||||
t.Fatalf("unexpected policy config: %#v", config)
|
||||
}
|
||||
if config.reconnectionBaseDelay != 4*time.Second || config.reconnectionMaxDelay != 20*time.Second {
|
||||
t.Fatalf("unexpected reconnection delays: %#v", config)
|
||||
}
|
||||
if !config.ssl || config.hostVerification {
|
||||
t.Fatalf("unexpected TLS config: %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCassandraConfigFileSupportsKerberosLoginOptionCasing(t *testing.T) {
|
||||
parsed, err := hocon.ParseString(`
|
||||
datastax-java-driver.advanced.auth-provider {
|
||||
class = com.instaclustr.cassandra.driver.auth.KerberosAuthProvider
|
||||
authorization-id = assumed_role
|
||||
sasl-protocol = cassandra-custom
|
||||
sasl-properties."javax.security.sasl.qop" = auth
|
||||
login-configuration {
|
||||
principal = "alice@EXAMPLE.COM"
|
||||
keyTab = "/tmp/alice.keytab"
|
||||
ticketCache = "FILE:/tmp/alice.ccache"
|
||||
useKeyTab = true
|
||||
useTicketCache = false
|
||||
}
|
||||
}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := cassandraConfig{kerberos: defaultKerberosConfig()}
|
||||
if err := applyJavaDriverHOCON(&config, parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !config.kerberos.enabled || config.kerberos.principal != "alice@EXAMPLE.COM" {
|
||||
t.Fatalf("unexpected Kerberos provider config: %#v", config.kerberos)
|
||||
}
|
||||
if config.kerberos.keytabPath != "/tmp/alice.keytab" || config.kerberos.ccachePath != "FILE:/tmp/alice.ccache" {
|
||||
t.Fatalf("unexpected Kerberos file options: %#v", config.kerberos)
|
||||
}
|
||||
if !config.kerberos.useKeytab || !config.kerberos.useKeytabSet || config.kerberos.useTicketCache || !config.kerberos.useTicketCacheSet {
|
||||
t.Fatalf("unexpected Kerberos credential switches: %#v", config.kerberos)
|
||||
}
|
||||
if config.kerberos.authorizationID != "assumed_role" || config.kerberos.serviceName != "cassandra-custom" || config.kerberos.qop != "auth" {
|
||||
t.Fatalf("unexpected Kerberos SASL options: %#v", config.kerberos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingCassandraConfigFileIsIgnoredForJDBCCompatibility(t *testing.T) {
|
||||
missingPath := filepath.Join(t.TempDir(), "missing.conf")
|
||||
config, err := parseCassandraConfig(connectParams{
|
||||
Host: "localhost",
|
||||
URLParams: url.Values{"configfile": []string{missingPath}, "requesttimeout": []string{"2s"}}.Encode(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.requestTimeout != 2*time.Second {
|
||||
t.Fatalf("missing configfile must leave URL options intact: %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, name, contents string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -128,16 +128,15 @@ func TestParseCassandraConfigAcceptsDefaultSSLEngineFactory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCassandraConfigRejectsJavaOnlyJDBCOptions(t *testing.T) {
|
||||
func TestParseCassandraConfigRejectsCustomJavaImplementationClasses(t *testing.T) {
|
||||
tests := []string{
|
||||
"configfile=/tmp/application.conf",
|
||||
"secureconnectbundle=/tmp/secure-connect.zip",
|
||||
"usekrb5=true",
|
||||
"sslenginefactory=example.CustomSslEngineFactory",
|
||||
"loadbalancing=example.CustomPolicy",
|
||||
"retry=example.CustomRetryPolicy",
|
||||
}
|
||||
for _, urlParams := range tests {
|
||||
if _, err := parseCassandraConfig(connectParams{Host: "localhost", URLParams: urlParams}); err == nil {
|
||||
t.Fatalf("expected unsupported Java-only option error for %q", urlParams)
|
||||
t.Fatalf("expected custom Java implementation rejection for %q", urlParams)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,28 @@ module github.com/t8y2/dbx/agents/drivers/cassandra-go
|
||||
|
||||
go 1.22
|
||||
|
||||
require github.com/apache/cassandra-gocql-driver/v2 v2.1.2
|
||||
require (
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.1.2
|
||||
github.com/datastax/gocql-astra/v2 v2.0.1
|
||||
github.com/gurkankaymak/hocon v1.3.0
|
||||
github.com/jcmturner/gofork v1.7.6
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4
|
||||
)
|
||||
|
||||
require gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
require (
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/datastax/astra-client-go/v2 v2.2.54 // indirect
|
||||
github.com/datastax/cql-proxy v0.1.6 // indirect
|
||||
github.com/datastax/go-cassandra-native-protocol v0.0.0-20220706104457-5e8aad05cf90 // indirect
|
||||
github.com/deepmap/oapi-codegen v1.12.4 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/crypto v0.6.0 // indirect
|
||||
golang.org/x/net v0.7.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,9 +1,46 @@
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.1.2 h1:lu/p0Db2av18enHJvWJQoChLssI0P+AR06STq4VdvCc=
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.1.2/go.mod h1:QH/asJjB3mHvY6Dot6ZKMMpTcOrWJ8i9GhsvG1g0PK4=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/datastax/astra-client-go/v2 v2.2.54 h1:R2k9ek9zaU15cLD96np5gsj12oZhK3Z5/tSytjQagO8=
|
||||
github.com/datastax/astra-client-go/v2 v2.2.54/go.mod h1:zxXWuqDkYia7PzFIL3T7RmjChc9LN81UnfI2yB4kE7M=
|
||||
github.com/datastax/cql-proxy v0.1.6 h1:IFJ/QV5Hk25CVaqVzPAz9o3ZsczZKKE3htpeNk3/e9o=
|
||||
github.com/datastax/cql-proxy v0.1.6/go.mod h1:AgfO39+s+6oSwMETjysTnr5dhDArYfiDoMz0J9RG72I=
|
||||
github.com/datastax/go-cassandra-native-protocol v0.0.0-20220706104457-5e8aad05cf90 h1:SiFe3gwoHPt95ly6HLjwyyItxROxCUJuxqqTnguR5ac=
|
||||
github.com/datastax/go-cassandra-native-protocol v0.0.0-20220706104457-5e8aad05cf90/go.mod h1:6FzirJfdffakAVqmHjwVfFkpru/gNbIazUOK5rIhndc=
|
||||
github.com/datastax/gocql-astra/v2 v2.0.1 h1:yZo7pN51mn/D8LlA7FXFrkYeEw2OrsbGcAZLfY9dx6M=
|
||||
github.com/datastax/gocql-astra/v2 v2.0.1/go.mod h1:dnBksaFCHOIHWqVqDploY3/J8G+0PO/9193VBphW0cA=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deepmap/oapi-codegen v1.12.4 h1:pPmn6qI9MuOtCz82WY2Xaw46EQjgvxednXXrP7g5Q2s=
|
||||
github.com/deepmap/oapi-codegen v1.12.4/go.mod h1:3lgHGMu6myQ2vqbbTXH2H1o4eXFTGnFiDaOaKKl5yas=
|
||||
github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA=
|
||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gurkankaymak/hocon v1.3.0 h1:OPw9XP+c3o4JGBVIS5e4+84YNfFFFgKQ625LX5tjSyQ=
|
||||
github.com/gurkankaymak/hocon v1.3.0/go.mod h1:CM7yeDDq8AUU7I+QiLA7aVSJVbP1BVgZqqCiuzgcNS0=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
@@ -14,15 +51,64 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/atomic v1.8.0 h1:CUhrE4N1rqSE6FM9ecihEjRkLQu8cDfgDyoOs83mEY4=
|
||||
go.uber.org/atomic v1.8.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
"github.com/jcmturner/gofork/encoding/asn1"
|
||||
"github.com/jcmturner/gokrb5/v8/asn1tools"
|
||||
krb5client "github.com/jcmturner/gokrb5/v8/client"
|
||||
krb5config "github.com/jcmturner/gokrb5/v8/config"
|
||||
"github.com/jcmturner/gokrb5/v8/credentials"
|
||||
"github.com/jcmturner/gokrb5/v8/gssapi"
|
||||
"github.com/jcmturner/gokrb5/v8/iana/chksumtype"
|
||||
"github.com/jcmturner/gokrb5/v8/iana/keyusage"
|
||||
"github.com/jcmturner/gokrb5/v8/keytab"
|
||||
"github.com/jcmturner/gokrb5/v8/messages"
|
||||
"github.com/jcmturner/gokrb5/v8/types"
|
||||
)
|
||||
|
||||
const (
|
||||
kerberosAPRequestTokenID = 0x0100
|
||||
kerberosGSSAPITag = 0x60
|
||||
kerberosSecurityNone = 0x01
|
||||
)
|
||||
|
||||
type kerberosCredentialMode int
|
||||
|
||||
const (
|
||||
kerberosCredentialNone kerberosCredentialMode = iota
|
||||
kerberosCredentialPassword
|
||||
kerberosCredentialKeytab
|
||||
kerberosCredentialCCache
|
||||
)
|
||||
|
||||
type kerberosConfig struct {
|
||||
enabled bool
|
||||
configPath string
|
||||
jaasConfigPath string
|
||||
principal string
|
||||
realm string
|
||||
keytabPath string
|
||||
ccachePath string
|
||||
password string
|
||||
serviceName string
|
||||
serverName string
|
||||
authorizationID string
|
||||
qop string
|
||||
disablePAFXFAST bool
|
||||
useKeytab bool
|
||||
useKeytabSet bool
|
||||
useTicketCache bool
|
||||
useTicketCacheSet bool
|
||||
credentialMode kerberosCredentialMode
|
||||
credentialUser string
|
||||
credentialRealm string
|
||||
}
|
||||
|
||||
type kerberosAuthenticator struct {
|
||||
domain string
|
||||
clientName types.PrincipalName
|
||||
ticket messages.Ticket
|
||||
sessionKey types.EncryptionKey
|
||||
authorizationID string
|
||||
step int
|
||||
}
|
||||
|
||||
var (
|
||||
jaasBlockPattern = regexp.MustCompile(`(?is)\bCassandraJavaClient\s*\{(.*?)\}\s*;`)
|
||||
jaasModulePattern = regexp.MustCompile(`(?is)\bcom\.sun\.security\.auth\.module\.Krb5LoginModule\b(.*?);`)
|
||||
jaasOptionPattern = regexp.MustCompile(`(?is)([A-Za-z][A-Za-z0-9_-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s;]+))`)
|
||||
)
|
||||
|
||||
func defaultKerberosConfig() kerberosConfig {
|
||||
return kerberosConfig{
|
||||
serviceName: "cassandra",
|
||||
qop: "auth",
|
||||
}
|
||||
}
|
||||
|
||||
func (config *kerberosConfig) finalize(username, password string) error {
|
||||
config.applyJavaSystemProperties()
|
||||
if config.jaasConfigPath != "" {
|
||||
path, err := normalizeLocalFilePath(config.jaasConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Cassandra JAAS config path: %w", err)
|
||||
}
|
||||
config.jaasConfigPath = path
|
||||
if err := config.applyJAASConfig(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
config.applyKerberosConfigEnvironment()
|
||||
if config.configPath == "" {
|
||||
config.configPath = defaultKerberosConfigPath()
|
||||
}
|
||||
path, err := normalizeLocalFilePath(firstPathListEntry(config.configPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Kerberos config path: %w", err)
|
||||
}
|
||||
config.configPath = path
|
||||
if err := requireRegularFile("Kerberos config", config.configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
krbConfig, err := krb5config.Load(config.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Kerberos config %s: %w", config.configPath, err)
|
||||
}
|
||||
if config.serviceName == "" {
|
||||
config.serviceName = "cassandra"
|
||||
}
|
||||
if !kerberosQOPIncludesAuth(config.qop) {
|
||||
return fmt.Errorf("Cassandra Kerberos currently supports SASL QOP auth only, got %s", config.qop)
|
||||
}
|
||||
config.qop = "auth"
|
||||
if config.principal == "" {
|
||||
config.principal = strings.TrimSpace(username)
|
||||
}
|
||||
if config.password == "" {
|
||||
config.password = password
|
||||
}
|
||||
if config.useTicketCache {
|
||||
return config.selectCCacheCredential()
|
||||
}
|
||||
if config.useKeytab {
|
||||
return config.selectKeytabCredential(krbConfig)
|
||||
}
|
||||
if config.ccachePath != "" && !config.useTicketCacheSet {
|
||||
return config.selectCCacheCredential()
|
||||
}
|
||||
if config.keytabPath != "" && !config.useKeytabSet {
|
||||
return config.selectKeytabCredential(krbConfig)
|
||||
}
|
||||
if config.principal != "" && config.password != "" {
|
||||
config.credentialUser, config.credentialRealm, err = splitKerberosPrincipal(
|
||||
config.principal,
|
||||
config.realm,
|
||||
krbConfig.LibDefaults.DefaultRealm,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.credentialMode = kerberosCredentialPassword
|
||||
return nil
|
||||
}
|
||||
if !config.useTicketCacheSet {
|
||||
config.ccachePath = os.Getenv("KRB5CCNAME")
|
||||
if config.ccachePath == "" {
|
||||
defaultCache := defaultKerberosCCachePath()
|
||||
if path, normalizeErr := normalizeKerberosCachePath(defaultCache); normalizeErr == nil {
|
||||
if info, statErr := os.Stat(path); statErr == nil && info.Mode().IsRegular() {
|
||||
config.ccachePath = defaultCache
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.ccachePath != "" {
|
||||
return config.selectCCacheCredential()
|
||||
}
|
||||
}
|
||||
if !config.useKeytabSet {
|
||||
config.keytabPath = firstNonEmpty(os.Getenv("KRB5_CLIENT_KTNAME"), os.Getenv("KRB5_KTNAME"))
|
||||
if config.keytabPath != "" {
|
||||
return config.selectKeytabCredential(krbConfig)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("Kerberos authentication requires a credential cache, keytab, or principal and password")
|
||||
}
|
||||
|
||||
func (config *kerberosConfig) selectCCacheCredential() error {
|
||||
var err error
|
||||
if config.ccachePath == "" {
|
||||
config.ccachePath = defaultKerberosCCachePath()
|
||||
}
|
||||
config.ccachePath, err = normalizeKerberosCachePath(config.ccachePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireRegularFile("Kerberos credential cache", config.ccachePath); err != nil {
|
||||
return err
|
||||
}
|
||||
config.credentialMode = kerberosCredentialCCache
|
||||
return nil
|
||||
}
|
||||
|
||||
func (config *kerberosConfig) selectKeytabCredential(krbConfig *krb5config.Config) error {
|
||||
var err error
|
||||
if config.keytabPath == "" {
|
||||
config.keytabPath = firstNonEmpty(os.Getenv("KRB5_CLIENT_KTNAME"), os.Getenv("KRB5_KTNAME"))
|
||||
if config.keytabPath == "" {
|
||||
return fmt.Errorf("Kerberos keytab authentication requires a keytab path")
|
||||
}
|
||||
}
|
||||
config.keytabPath, err = normalizeKerberosFileReference(config.keytabPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireRegularFile("Kerberos keytab", config.keytabPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if config.principal == "" {
|
||||
config.principal, err = principalFromKeytab(config.keytabPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
config.credentialUser, config.credentialRealm, err = splitKerberosPrincipal(
|
||||
config.principal,
|
||||
config.realm,
|
||||
krbConfig.LibDefaults.DefaultRealm,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.credentialMode = kerberosCredentialKeytab
|
||||
return nil
|
||||
}
|
||||
|
||||
func newKerberosAuthProvider(
|
||||
config kerberosConfig,
|
||||
username string,
|
||||
password string,
|
||||
) (func(*gocql.HostInfo) (gocql.Authenticator, error), error) {
|
||||
if !config.enabled {
|
||||
return nil, fmt.Errorf("Kerberos authentication is not enabled")
|
||||
}
|
||||
if config.credentialMode == kerberosCredentialNone {
|
||||
if err := config.finalize(username, password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
krbConfig, err := krb5config.Load(config.configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Kerberos config %s: %w", config.configPath, err)
|
||||
}
|
||||
return func(host *gocql.HostInfo) (gocql.Authenticator, error) {
|
||||
return newKerberosAuthenticator(config, krbConfig, host)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newKerberosAuthenticator(
|
||||
config kerberosConfig,
|
||||
krbConfig *krb5config.Config,
|
||||
host *gocql.HostInfo,
|
||||
) (gocql.Authenticator, error) {
|
||||
client, err := newKerberosClient(config, krbConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := client.Login(); err != nil {
|
||||
client.Destroy()
|
||||
return nil, fmt.Errorf("Kerberos login failed: %w", err)
|
||||
}
|
||||
serverName, err := kerberosServerName(config, host)
|
||||
if err != nil {
|
||||
client.Destroy()
|
||||
return nil, err
|
||||
}
|
||||
servicePrincipal := config.serviceName + "/" + serverName
|
||||
ticket, sessionKey, err := client.GetServiceTicket(servicePrincipal)
|
||||
if err != nil {
|
||||
client.Destroy()
|
||||
return nil, fmt.Errorf("get Kerberos service ticket for %s: %w", servicePrincipal, err)
|
||||
}
|
||||
clientName := client.Credentials.CName()
|
||||
clientName.NameString = append([]string(nil), clientName.NameString...)
|
||||
authenticator := &kerberosAuthenticator{
|
||||
domain: strings.Clone(client.Credentials.Domain()),
|
||||
clientName: clientName,
|
||||
ticket: ticket,
|
||||
sessionKey: sessionKey,
|
||||
authorizationID: config.authorizationID,
|
||||
}
|
||||
client.Destroy()
|
||||
return authenticator, nil
|
||||
}
|
||||
|
||||
func newKerberosClient(config kerberosConfig, krbConfig *krb5config.Config) (*krb5client.Client, error) {
|
||||
settings := []func(*krb5client.Settings){krb5client.DisablePAFXFAST(config.disablePAFXFAST)}
|
||||
switch config.credentialMode {
|
||||
case kerberosCredentialCCache:
|
||||
cache, err := credentials.LoadCCache(config.ccachePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Kerberos credential cache %s: %w", config.ccachePath, err)
|
||||
}
|
||||
client, err := krb5client.NewFromCCache(cache, krbConfig, settings...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create Kerberos client from credential cache: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
case kerberosCredentialKeytab:
|
||||
loadedKeytab, err := keytab.Load(config.keytabPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Kerberos keytab %s: %w", config.keytabPath, err)
|
||||
}
|
||||
return krb5client.NewWithKeytab(
|
||||
config.credentialUser,
|
||||
config.credentialRealm,
|
||||
loadedKeytab,
|
||||
krbConfig,
|
||||
settings...,
|
||||
), nil
|
||||
case kerberosCredentialPassword:
|
||||
return krb5client.NewWithPassword(
|
||||
config.credentialUser,
|
||||
config.credentialRealm,
|
||||
config.password,
|
||||
krbConfig,
|
||||
settings...,
|
||||
), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("Kerberos credentials are not configured")
|
||||
}
|
||||
}
|
||||
|
||||
func (authenticator *kerberosAuthenticator) Challenge(request []byte) ([]byte, gocql.Authenticator, error) {
|
||||
switch authenticator.step {
|
||||
case 0:
|
||||
token, err := authenticator.initialToken()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
authenticator.step = 1
|
||||
return token, authenticator, nil
|
||||
case 1:
|
||||
token, err := authenticator.securityLayerResponse(request)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
authenticator.step = 2
|
||||
return token, authenticator, nil
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unexpected Cassandra Kerberos authentication challenge")
|
||||
}
|
||||
}
|
||||
|
||||
func (authenticator *kerberosAuthenticator) Success(_ []byte) error {
|
||||
if authenticator.step != 2 {
|
||||
return fmt.Errorf("Cassandra reported Kerberos success before SASL negotiation completed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (authenticator *kerberosAuthenticator) initialToken() ([]byte, error) {
|
||||
value, err := types.NewAuthenticator(authenticator.domain, authenticator.clientName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value.Cksum = types.Checksum{
|
||||
CksumType: chksumtype.GSSAPI,
|
||||
Checksum: kerberosAuthenticatorChecksum(),
|
||||
}
|
||||
request, err := messages.NewAPReq(authenticator.ticket, authenticator.sessionKey, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(payload, kerberosAPRequestTokenID)
|
||||
encodedRequest, err := request.Marshal()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload = append(payload, encodedRequest...)
|
||||
encodedOID, err := asn1.Marshal(gssapi.OIDKRB5.OID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
header := append([]byte{kerberosGSSAPITag}, asn1tools.MarshalLengthBytes(len(encodedOID)+len(payload))...)
|
||||
header = append(header, encodedOID...)
|
||||
return append(header, payload...), nil
|
||||
}
|
||||
|
||||
func (authenticator *kerberosAuthenticator) securityLayerResponse(challenge []byte) ([]byte, error) {
|
||||
var request gssapi.WrapToken
|
||||
if err := request.Unmarshal(challenge, true); err != nil {
|
||||
return nil, fmt.Errorf("decode Kerberos SASL security-layer challenge: %w", err)
|
||||
}
|
||||
valid, err := request.Verify(authenticator.sessionKey, keyusage.GSSAPI_ACCEPTOR_SEAL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify Kerberos SASL security-layer challenge: %w", err)
|
||||
}
|
||||
if !valid {
|
||||
return nil, fmt.Errorf("invalid Kerberos SASL security-layer challenge")
|
||||
}
|
||||
if len(request.Payload) < 4 || request.Payload[0]&kerberosSecurityNone == 0 {
|
||||
return nil, fmt.Errorf("Cassandra Kerberos server does not allow SASL QOP auth")
|
||||
}
|
||||
payload := []byte{kerberosSecurityNone, 0, 0, 0}
|
||||
payload = append(payload, authenticator.authorizationID...)
|
||||
response, err := gssapi.NewInitiatorWrapToken(payload, authenticator.sessionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Marshal()
|
||||
}
|
||||
|
||||
func kerberosAuthenticatorChecksum() []byte {
|
||||
checksum := make([]byte, 24)
|
||||
binary.LittleEndian.PutUint32(checksum[:4], 16)
|
||||
flags := uint32(gssapi.ContextFlagInteg | gssapi.ContextFlagConf)
|
||||
binary.LittleEndian.PutUint32(checksum[20:24], flags)
|
||||
return checksum
|
||||
}
|
||||
|
||||
func kerberosQOPIncludesAuth(value string) bool {
|
||||
for _, qop := range strings.Split(value, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(qop), "auth") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func kerberosServerName(config kerberosConfig, host *gocql.HostInfo) (string, error) {
|
||||
if config.serverName != "" {
|
||||
return strings.TrimSuffix(strings.TrimSpace(config.serverName), "."), nil
|
||||
}
|
||||
if host == nil {
|
||||
return "", fmt.Errorf("resolve Kerberos server name: Cassandra host is unavailable")
|
||||
}
|
||||
address := host.ConnectAddress()
|
||||
if address != nil {
|
||||
names, err := net.LookupAddr(address.String())
|
||||
if err == nil && len(names) > 0 {
|
||||
return strings.TrimSuffix(strings.TrimSpace(names[0]), "."), nil
|
||||
}
|
||||
}
|
||||
hostname, _, err := net.SplitHostPort(host.HostnameAndPort())
|
||||
if err == nil && hostname != "" && net.ParseIP(hostname) == nil {
|
||||
return strings.TrimSuffix(hostname, "."), nil
|
||||
}
|
||||
return "", fmt.Errorf("resolve Kerberos server name for Cassandra host %s; configure kerberosservername explicitly", host.ConnectAddressAndPort())
|
||||
}
|
||||
|
||||
func (config *kerberosConfig) applyJavaSystemProperties() {
|
||||
if config.jaasConfigPath == "" {
|
||||
config.jaasConfigPath = javaSystemProperty("java.security.auth.login.config")
|
||||
}
|
||||
if config.configPath == "" {
|
||||
config.configPath = javaSystemProperty("java.security.krb5.conf")
|
||||
}
|
||||
}
|
||||
|
||||
func (config *kerberosConfig) applyKerberosConfigEnvironment() {
|
||||
if config.configPath == "" {
|
||||
config.configPath = os.Getenv("KRB5_CONFIG")
|
||||
}
|
||||
}
|
||||
|
||||
func (config *kerberosConfig) applyJAASConfig(path string) error {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Cassandra JAAS config %s: %w", path, err)
|
||||
}
|
||||
block := jaasBlockPattern.FindSubmatch(contents)
|
||||
if len(block) != 2 {
|
||||
return fmt.Errorf("Cassandra JAAS config %s does not contain CassandraJavaClient", path)
|
||||
}
|
||||
module := jaasModulePattern.FindSubmatch(block[1])
|
||||
if len(module) != 2 {
|
||||
return fmt.Errorf("CassandraJavaClient in %s does not configure Krb5LoginModule", path)
|
||||
}
|
||||
options := map[string]string{}
|
||||
for _, match := range jaasOptionPattern.FindAllSubmatch(module[1], -1) {
|
||||
value := firstNonEmpty(string(match[2]), string(match[3]), string(match[4]))
|
||||
options[strings.ToLower(string(match[1]))] = value
|
||||
}
|
||||
if config.principal == "" {
|
||||
config.principal = options["principal"]
|
||||
}
|
||||
if config.keytabPath == "" {
|
||||
config.keytabPath = options["keytab"]
|
||||
}
|
||||
if config.ccachePath == "" {
|
||||
config.ccachePath = options["ticketcache"]
|
||||
}
|
||||
if value, ok := options["usekeytab"]; ok {
|
||||
config.useKeytab, err = strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid useKeyTab in Cassandra JAAS config: %w", err)
|
||||
}
|
||||
config.useKeytabSet = true
|
||||
}
|
||||
if value, ok := options["useticketcache"]; ok {
|
||||
config.useTicketCache, err = strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid useTicketCache in Cassandra JAAS config: %w", err)
|
||||
}
|
||||
config.useTicketCacheSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func javaSystemProperty(name string) string {
|
||||
pattern := regexp.MustCompile(`(?:^|\s)-D` + regexp.QuoteMeta(name) + `=(?:"([^"]*)"|'([^']*)'|(\S+))`)
|
||||
for _, environmentName := range []string{"JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS"} {
|
||||
match := pattern.FindStringSubmatch(os.Getenv(environmentName))
|
||||
if len(match) == 4 {
|
||||
return firstNonEmpty(match[1], match[2], match[3])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeKerberosCachePath(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("Kerberos ticket cache path is empty")
|
||||
}
|
||||
if separator := strings.IndexByte(value, ':'); separator > 0 && !isWindowsDrivePath(value) {
|
||||
cacheType := strings.ToUpper(value[:separator])
|
||||
if cacheType != "FILE" {
|
||||
return "", fmt.Errorf("Kerberos credential cache type %s is not supported; use a FILE cache or keytab", cacheType)
|
||||
}
|
||||
value = value[separator+1:]
|
||||
}
|
||||
path, err := normalizeLocalFilePath(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid Kerberos credential cache path: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isWindowsDrivePath(value string) bool {
|
||||
return len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') || (value[0] >= 'a' && value[0] <= 'z')) &&
|
||||
value[1] == ':' && (value[2] == '\\' || value[2] == '/')
|
||||
}
|
||||
|
||||
func normalizeKerberosFileReference(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(strings.ToUpper(value), "FILE:") {
|
||||
value = value[5:]
|
||||
}
|
||||
path, err := normalizeLocalFilePath(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid Kerberos file path: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func splitKerberosPrincipal(principal, configuredRealm, defaultRealm string) (string, string, error) {
|
||||
value := strings.TrimSpace(principal)
|
||||
realm := strings.TrimSpace(configuredRealm)
|
||||
if separator := strings.LastIndexByte(value, '@'); separator >= 0 {
|
||||
realm = value[separator+1:]
|
||||
value = value[:separator]
|
||||
}
|
||||
if value == "" {
|
||||
return "", "", fmt.Errorf("Kerberos principal is empty")
|
||||
}
|
||||
if realm == "" {
|
||||
realm = strings.TrimSpace(defaultRealm)
|
||||
}
|
||||
if realm == "" {
|
||||
return "", "", fmt.Errorf("Kerberos realm is required for principal %s", principal)
|
||||
}
|
||||
return value, realm, nil
|
||||
}
|
||||
|
||||
func principalFromKeytab(path string) (string, error) {
|
||||
loadedKeytab, err := keytab.Load(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load Kerberos keytab %s: %w", path, err)
|
||||
}
|
||||
principals := map[string]struct{}{}
|
||||
for _, entry := range loadedKeytab.Entries {
|
||||
principals[entry.Principal.String()] = struct{}{}
|
||||
}
|
||||
if len(principals) != 1 {
|
||||
return "", fmt.Errorf("Kerberos keytab %s contains %d principals; configure kerberosprincipal explicitly", path, len(principals))
|
||||
}
|
||||
for principal := range principals {
|
||||
return principal, nil
|
||||
}
|
||||
return "", fmt.Errorf("Kerberos keytab %s contains no principals", path)
|
||||
}
|
||||
|
||||
func defaultKerberosConfigPath() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
if windowsDirectory := os.Getenv("WINDIR"); windowsDirectory != "" {
|
||||
return filepath.Join(windowsDirectory, "krb5.ini")
|
||||
}
|
||||
}
|
||||
return "/etc/krb5.conf"
|
||||
}
|
||||
|
||||
func defaultKerberosCCachePath() string {
|
||||
if value := os.Getenv("KRB5CCNAME"); value != "" {
|
||||
return value
|
||||
}
|
||||
currentUser, err := user.Current()
|
||||
if err == nil && currentUser.Uid != "" {
|
||||
return filepath.Join(os.TempDir(), "krb5cc_"+currentUser.Uid)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstPathListEntry(value string) string {
|
||||
entries := filepath.SplitList(value)
|
||||
if len(entries) == 0 {
|
||||
return value
|
||||
}
|
||||
return entries[0]
|
||||
}
|
||||
|
||||
func requireRegularFile(label, path string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("%s path is empty", label)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s %s: %w", label, path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s is not a regular file: %s", label, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
"github.com/jcmturner/gofork/encoding/asn1"
|
||||
"github.com/jcmturner/gokrb5/v8/gssapi"
|
||||
"github.com/jcmturner/gokrb5/v8/iana/keyusage"
|
||||
"github.com/jcmturner/gokrb5/v8/messages"
|
||||
"github.com/jcmturner/gokrb5/v8/types"
|
||||
)
|
||||
|
||||
func TestKerberosPasswordCredentialsTakePrecedenceOverEnvironmentCache(t *testing.T) {
|
||||
clearKerberosEnvironment(t)
|
||||
t.Setenv("KRB5CCNAME", filepath.Join(t.TempDir(), "missing.ccache"))
|
||||
config := defaultKerberosConfig()
|
||||
config.enabled = true
|
||||
config.configPath = writeKerberosConfig(t)
|
||||
config.principal = "alice@EXAMPLE.COM"
|
||||
config.password = "secret"
|
||||
|
||||
if err := config.finalize("", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.credentialMode != kerberosCredentialPassword || config.credentialUser != "alice" || config.credentialRealm != "EXAMPLE.COM" {
|
||||
t.Fatalf("unexpected password credential selection: %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosExplicitCredentialSourcesTakePrecedence(t *testing.T) {
|
||||
clearKerberosEnvironment(t)
|
||||
krb5Path := writeKerberosConfig(t)
|
||||
cachePath := writeTestFile(t, "alice.ccache", "placeholder")
|
||||
keytabPath := writeTestFile(t, "alice.keytab", "placeholder")
|
||||
|
||||
t.Run("ccache", func(t *testing.T) {
|
||||
config := defaultKerberosConfig()
|
||||
config.enabled = true
|
||||
config.configPath = krb5Path
|
||||
config.ccachePath = "FILE:" + cachePath
|
||||
config.principal = "alice@EXAMPLE.COM"
|
||||
config.password = "ignored"
|
||||
if err := config.finalize("", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.credentialMode != kerberosCredentialCCache || config.ccachePath != cachePath {
|
||||
t.Fatalf("unexpected ccache credential selection: %#v", config)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keytab", func(t *testing.T) {
|
||||
config := defaultKerberosConfig()
|
||||
config.enabled = true
|
||||
config.configPath = krb5Path
|
||||
config.keytabPath = "FILE:" + keytabPath
|
||||
config.principal = "alice@EXAMPLE.COM"
|
||||
config.password = "ignored"
|
||||
if err := config.finalize("", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.credentialMode != kerberosCredentialKeytab || config.keytabPath != keytabPath {
|
||||
t.Fatalf("unexpected keytab credential selection: %#v", config)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestKerberosDiscoversJavaJAASAndKrb5Properties(t *testing.T) {
|
||||
clearKerberosEnvironment(t)
|
||||
krb5Path := writeKerberosConfig(t)
|
||||
cachePath := writeTestFile(t, "alice.ccache", "placeholder")
|
||||
jaasPath := writeTestFile(t, "jaas.conf", `
|
||||
CassandraJavaClient {
|
||||
com.sun.security.auth.module.Krb5LoginModule required
|
||||
useTicketCache=true
|
||||
ticketCache="FILE:`+cachePath+`"
|
||||
principal="alice@EXAMPLE.COM";
|
||||
};
|
||||
`)
|
||||
t.Setenv("JAVA_TOOL_OPTIONS", `-Djava.security.krb5.conf="`+krb5Path+`" -Djava.security.auth.login.config='`+jaasPath+`'`)
|
||||
|
||||
config := defaultKerberosConfig()
|
||||
config.enabled = true
|
||||
if err := config.finalize("", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.configPath != krb5Path || config.jaasConfigPath != jaasPath {
|
||||
t.Fatalf("Java system properties were not applied: %#v", config)
|
||||
}
|
||||
if config.credentialMode != kerberosCredentialCCache || config.ccachePath != cachePath || config.principal != "alice@EXAMPLE.COM" {
|
||||
t.Fatalf("JAAS credential cache was not applied: %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosRejectsUnsupportedQOP(t *testing.T) {
|
||||
clearKerberosEnvironment(t)
|
||||
config := defaultKerberosConfig()
|
||||
config.enabled = true
|
||||
config.configPath = writeKerberosConfig(t)
|
||||
config.principal = "alice@EXAMPLE.COM"
|
||||
config.password = "secret"
|
||||
config.qop = "auth-conf"
|
||||
|
||||
err := config.finalize("", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "supports SASL QOP auth only") {
|
||||
t.Fatalf("expected QOP rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosAcceptsQOPPreferenceListContainingAuth(t *testing.T) {
|
||||
clearKerberosEnvironment(t)
|
||||
config := defaultKerberosConfig()
|
||||
config.enabled = true
|
||||
config.configPath = writeKerberosConfig(t)
|
||||
config.principal = "alice@EXAMPLE.COM"
|
||||
config.password = "secret"
|
||||
config.qop = "auth-conf, auth"
|
||||
|
||||
if err := config.finalize("", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.qop != "auth" {
|
||||
t.Fatalf("unexpected negotiated QOP preference: %q", config.qop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKerberosCachePathSupportsWindowsDrivePaths(t *testing.T) {
|
||||
path, err := normalizeKerberosCachePath(`C:\Users\alice\krb5cc`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path != `C:\Users\alice\krb5cc` {
|
||||
t.Fatalf("unexpected Windows cache path: %q", path)
|
||||
}
|
||||
if _, err := normalizeKerberosCachePath("DIR:/tmp/krb5cc"); err == nil {
|
||||
t.Fatal("expected non-FILE credential cache type rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosServerNameSupportsExplicitOverride(t *testing.T) {
|
||||
name, err := kerberosServerName(kerberosConfig{serverName: "node1.example.com."}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "node1.example.com" {
|
||||
t.Fatalf("unexpected explicit server name: %q", name)
|
||||
}
|
||||
if _, err := kerberosServerName(kerberosConfig{}, nil); err == nil {
|
||||
t.Fatal("expected missing host error")
|
||||
}
|
||||
|
||||
host, err := gocql.NewHostInfoFromAddrPort(net.ParseIP("127.0.0.1"), 9042)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved, err := kerberosServerName(kerberosConfig{}, host); err != nil || strings.TrimSpace(resolved) == "" {
|
||||
t.Fatalf("expected loopback canonical name, got %q, %v", resolved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosInitialTokenContainsDecryptableAPRequest(t *testing.T) {
|
||||
key := testKerberosEncryptionKey()
|
||||
authenticator := kerberosAuthenticator{
|
||||
domain: "EXAMPLE.COM",
|
||||
clientName: types.NewPrincipalName(1, "alice"),
|
||||
ticket: messages.Ticket{
|
||||
TktVNO: 5,
|
||||
Realm: "EXAMPLE.COM",
|
||||
SName: types.NewPrincipalName(2, "cassandra/node1.example.com"),
|
||||
EncPart: types.EncryptedData{
|
||||
EType: key.KeyType,
|
||||
KVNO: 1,
|
||||
Cipher: []byte{1},
|
||||
},
|
||||
},
|
||||
sessionKey: key,
|
||||
}
|
||||
|
||||
token, err := authenticator.initialToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(token) < 2 || token[0] != kerberosGSSAPITag {
|
||||
t.Fatalf("unexpected GSSAPI token prefix: %x", token)
|
||||
}
|
||||
encodedOID, err := asn1.Marshal(gssapi.OIDKRB5.OID())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oidOffset := bytes.Index(token, encodedOID)
|
||||
if oidOffset < 0 {
|
||||
t.Fatalf("Kerberos OID missing from token: %x", token)
|
||||
}
|
||||
payload := token[oidOffset+len(encodedOID):]
|
||||
if len(payload) < 3 || binary.BigEndian.Uint16(payload[:2]) != kerberosAPRequestTokenID {
|
||||
t.Fatalf("unexpected Kerberos mechanism token: %x", payload)
|
||||
}
|
||||
var request messages.APReq
|
||||
if err := request.Unmarshal(payload[2:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := request.DecryptAuthenticator(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request.Authenticator.CName.PrincipalNameString() != "alice" || request.Authenticator.CRealm != "EXAMPLE.COM" {
|
||||
t.Fatalf("unexpected AP-REQ authenticator: %#v", request.Authenticator)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosSecurityLayerNegotiatesAuthAndAuthorizationID(t *testing.T) {
|
||||
key := testKerberosEncryptionKey()
|
||||
challenge := marshalKerberosAcceptorToken(t, key, []byte{0x07, 0x00, 0x10, 0x00})
|
||||
authenticator := kerberosAuthenticator{sessionKey: key, authorizationID: "assumed_role"}
|
||||
|
||||
response, err := authenticator.securityLayerResponse(challenge)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded gssapi.WrapToken
|
||||
if err := decoded.Unmarshal(response, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
valid, err := decoded.Verify(key, keyusage.GSSAPI_INITIATOR_SEAL)
|
||||
if err != nil || !valid {
|
||||
t.Fatalf("invalid security-layer response: valid=%t err=%v", valid, err)
|
||||
}
|
||||
want := append([]byte{kerberosSecurityNone, 0, 0, 0}, []byte("assumed_role")...)
|
||||
if !bytes.Equal(decoded.Payload, want) {
|
||||
t.Fatalf("unexpected security-layer payload: %x", decoded.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosSecurityLayerRejectsUnavailableAuthQOP(t *testing.T) {
|
||||
key := testKerberosEncryptionKey()
|
||||
challenge := marshalKerberosAcceptorToken(t, key, []byte{0x02, 0, 0, 0})
|
||||
authenticator := kerberosAuthenticator{sessionKey: key}
|
||||
if _, err := authenticator.securityLayerResponse(challenge); err == nil {
|
||||
t.Fatal("expected server QOP rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosAuthenticatorChecksumRequestsIntegrityAndConfidentiality(t *testing.T) {
|
||||
checksum := kerberosAuthenticatorChecksum()
|
||||
if len(checksum) != 24 || binary.LittleEndian.Uint32(checksum[:4]) != 16 {
|
||||
t.Fatalf("unexpected channel-binding checksum: %x", checksum)
|
||||
}
|
||||
wantFlags := uint32(gssapi.ContextFlagInteg | gssapi.ContextFlagConf)
|
||||
if flags := binary.LittleEndian.Uint32(checksum[20:24]); flags != wantFlags {
|
||||
t.Fatalf("unexpected GSSAPI context flags: %x", flags)
|
||||
}
|
||||
}
|
||||
|
||||
func clearKerberosEnvironment(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, name := range []string{
|
||||
"JAVA_TOOL_OPTIONS",
|
||||
"_JAVA_OPTIONS",
|
||||
"JDK_JAVA_OPTIONS",
|
||||
"KRB5_CONFIG",
|
||||
"KRB5CCNAME",
|
||||
"KRB5_CLIENT_KTNAME",
|
||||
"KRB5_KTNAME",
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
|
||||
func writeKerberosConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
return writeTestFile(t, "krb5.conf", `
|
||||
[libdefaults]
|
||||
default_realm = EXAMPLE.COM
|
||||
dns_lookup_realm = false
|
||||
dns_lookup_kdc = false
|
||||
|
||||
[realms]
|
||||
EXAMPLE.COM = {
|
||||
kdc = 127.0.0.1:88
|
||||
}
|
||||
`)
|
||||
}
|
||||
|
||||
func testKerberosEncryptionKey() types.EncryptionKey {
|
||||
return types.EncryptionKey{KeyType: 18, KeyValue: bytes.Repeat([]byte{0x42}, 32)}
|
||||
}
|
||||
|
||||
func marshalKerberosAcceptorToken(t *testing.T, key types.EncryptionKey, payload []byte) []byte {
|
||||
t.Helper()
|
||||
token := gssapi.WrapToken{
|
||||
Flags: 0x01,
|
||||
EC: 12,
|
||||
Payload: payload,
|
||||
}
|
||||
if err := token.SetCheckSum(key, keyusage.GSSAPI_ACCEPTOR_SEAL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, err := token.Marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
Reference in New Issue
Block a user