diff --git a/.cproject b/.cproject index 1e2acbd..6d6265b 100644 --- a/.cproject +++ b/.cproject @@ -22,8 +22,12 @@ - + @@ -35,6 +39,7 @@ + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6153e60..576610c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,7 +40,7 @@ jobs: path: xchange - name: Install build dependencies - run: sudo apt-get install libpopt-dev libreadline-dev libbsd-dev + run: sudo apt-get install libpopt-dev libreadline-dev libbsd-dev libssl-dev - name: Build static library run: make static diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b161b71..fa01c2d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -70,6 +70,9 @@ jobs: languages: ${{ matrix.language }} build-mode: manual + - name: Install build dependencies + run: sudo apt-get install libpopt-dev libreadline-dev libbsd-dev libssl-dev + - name: Manual build shell: bash env: @@ -77,6 +80,7 @@ jobs: run: | make -C xchange shared make shared + make tools - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml index 8d3f6e4..006c8dd 100644 --- a/.github/workflows/install.yml +++ b/.github/workflows/install.yml @@ -29,7 +29,7 @@ jobs: CC: gcc steps: - name: install build deps - run: sudo apt-get install doxygen libpopt-dev libreadline-dev libbsd-dev + run: sudo apt-get install doxygen libpopt-dev libreadline-dev libbsd-dev libssl-dev - name: Check out RedisX uses: actions/checkout@v4 diff --git a/Makefile b/Makefile index f839517..e93c80a 100644 --- a/Makefile +++ b/Makefile @@ -96,7 +96,8 @@ distclean: SOURCES = $(SRC)/redisx.c $(SRC)/resp.c $(SRC)/redisx-net.c $(SRC)/redisx-hooks.c \ $(SRC)/redisx-client.c $(SRC)/redisx-sentinel.c $(SRC)/redisx-cluster.c \ - $(SRC)/redisx-tab.c $(SRC)/redisx-sub.c $(SRC)/redisx-script.c + $(SRC)/redisx-tab.c $(SRC)/redisx-sub.c $(SRC)/redisx-script.c \ + $(SRC)/redisx-tls.c # Generate a list of object (obj/*.o) files from the input sources OBJECTS := $(subst $(SRC),$(OBJ),$(SOURCES)) diff --git a/config.mk b/config.mk index 4e85aac..1f59217 100644 --- a/config.mk +++ b/config.mk @@ -26,6 +26,9 @@ CC ?= gcc # Whether to use OpenMP WITH_OPENMP ?= 1 +# Whether to build with TLS support (via OpenSSL) +WITH_TLS ?= 1 + # Add include/ directory CPPFLAGS += -I$(INC) @@ -72,6 +75,11 @@ ifeq ($(WITH_OPENMP),1) LDFLAGS += -fopenmp endif +ifeq ($(WITH_TLS),1) + CPPFLAGS += -DWITH_TLS=1 + LDFLAGS += -lssl +endif + # Search for libraries under LIB ifneq ($(findstring $(LIB),$(LD_LIBRARY_PATH)),$LIB) LDFLAGS += -L$(LIB) diff --git a/include/redisx-priv.h b/include/redisx-priv.h index b451757..215a3be 100644 --- a/include/redisx-priv.h +++ b/include/redisx-priv.h @@ -15,9 +15,12 @@ #include #include +#if WITH_TLS +# include +#endif #define __XCHANGE_INTERNAL_API__ -#include +#include "redisx.h" #define IP_ADDRESS_LENGTH 40 ///< IPv6: 39 chars + termination. @@ -49,6 +52,10 @@ typedef struct { int available; ///< Number of bytes available in the buffer. int next; ///< Index of next unconsumed byte in buffer. int socket; ///< Changing the socket should require both locks! +#if WITH_TLS + SSL_CTX *ctx; + SSL *ssl; +#endif int pendingRequests; ///< Number of request sent and not yet answered... RESP *attributes; ///< Attributes from the last packet received. } ClientPrivate; @@ -60,6 +67,16 @@ typedef struct { int timeoutMillis; ///< [ms] Connection timeout for sentinel nodes. } RedisSentinel; +#if WITH_TLS +typedef struct { + boolean enabled; ///< Whether TLS is enabled. + char *certificate; ///< Certificate (mutual TLS only) + char *key; ///< Private key (mutual TLS only) + char *ca_certificate; ///< CA sertificate + char *dh_params; ///< (optional) parameter file for DH based cyphers +} TLSConfig; +#endif + /** * A set of configuration settings for a Redis server connection. * @@ -74,6 +91,10 @@ typedef struct { boolean hello; ///< whether to use HELLO (introduced in Redis 6.0.0 only) RedisSocketConfigurator socketConf; ///< Additional user configuration of client sockets +#if WITH_TLS + TLSConfig tls; ///< TLS configuration settings +#endif + Hook *firstCleanupCall; ///< Linked list of cleanup calls Hook *firstConnectCall; ///< Linked list of connection calls diff --git a/include/redisx.h b/include/redisx.h index 81d032a..b7e3fa0 100644 --- a/include/redisx.h +++ b/include/redisx.h @@ -404,6 +404,9 @@ Redis *redisxInitSentinel(const char *serviceName, const RedisServer *serverList int redisxValidateSentinel(const char *serviceName, const RedisServer *serverList, int nServers); int redisxCheckValid(const Redis *redis); void redisxDestroy(Redis *redis); +int redisxSetTLS(Redis *redis, const char *ca_file); +int redisxSetMutualTLS(Redis *redis, const char *cert_file, const char *key_file); +int redisxSetDHParams(Redis *redis, const char *dh_params_file); int redisxConnect(Redis *redis, boolean usePipeline); void redisxDisconnect(Redis *redis); int redisxReconnect(Redis *redis, boolean usePipeline); diff --git a/requirements.apt b/requirements.apt index 627f918..18f7194 100644 --- a/requirements.apt +++ b/requirements.apt @@ -1,4 +1,5 @@ # sudo apt install $(grep -v ^# requirements.apt) +libssl-dev libpopt-dev libreadline-dev libbsd-dev diff --git a/requirements.dnf b/requirements.dnf index cec0897..73109b0 100644 --- a/requirements.dnf +++ b/requirements.dnf @@ -1,4 +1,5 @@ # sudo dnf install $(grep -v ^# requirements.dnf) +openssl-devel popt-devel readline-devel libbsd-devel diff --git a/src/redisx-client.c b/src/redisx-client.c index f08c0d0..9718a54 100644 --- a/src/redisx-client.c +++ b/src/redisx-client.c @@ -18,6 +18,9 @@ #else # include #endif +#if WITH_TLS +# include +#endif #include "redisx-priv.h" @@ -104,6 +107,10 @@ static int rReadChunkAsync(ClientPrivate *cp) { errno = 0; cp->next = 0; +#if WITH_TLS + if(cp->ssl) cp->available = SSL_read(cp->ssl, cp->in, REDISX_RCVBUF_SIZE); + else +#endif cp->available = recv(sock, cp->in, REDISX_RCVBUF_SIZE, 0); trprintf(" ... read %d bytes from client %d socket.\n", cp->available, cp->idx); if(cp->available <= 0) { @@ -272,6 +279,10 @@ static int rSendBytesAsync(ClientPrivate *cp, const char *buf, int length, boole while(length > 0) { int n; +#if WITH_TLS + if(cp->ssl) n = SSL_write(cp->ssl, from, length); + else +#endif #if __linux__ // Linux supports flagging outgoing messages to inform it whether or not more // imminent data is on its way @@ -283,7 +294,7 @@ static int rSendBytesAsync(ClientPrivate *cp, const char *buf, int length, boole n = send(sock, from, length, 0); #endif - if(n < 0) { + if(n <= 0) { int status = rTransmitErrorAsync(cp, "send"); if(cp->isEnabled) x_trace(fn, NULL, status); cp->isEnabled = FALSE; diff --git a/src/redisx-net.c b/src/redisx-net.c index fa1d999..eaa5f38 100644 --- a/src/redisx-net.c +++ b/src/redisx-net.c @@ -340,6 +340,10 @@ static void rDisconnectClientAsync(RedisClient *cl) { * */ static void rResetClientAsync(RedisClient *cl) { +#if(WITH_TLS) + extern void rDestroyClientTLS(ClientPrivate *cp); +#endif + ClientPrivate *cp = (ClientPrivate *) cl->priv; pthread_mutex_lock(&cp->pendingLock); @@ -349,6 +353,12 @@ static void rResetClientAsync(RedisClient *cl) { cp->isEnabled = FALSE; cp->available = 0; cp->next = 0; + +#if(WITH_TLS) + rDestroyClientTLS(cp); +#endif + + cp->socket = -1; // Reset the channel's socket descriptor to 'unassigned' } @@ -611,6 +621,10 @@ static int rHelloAsync(RedisClient *cl, char *clientID) { int rConnectClient(Redis *redis, enum redisx_channel channel) { static const char *fn = "rConnectClient"; +#if WITH_TLS + extern int rConnectTLSClient(ClientPrivate *cp, const TLSConfig *tls); +#endif + struct sockaddr_in serverAddress; struct utsname u; RedisPrivate *p; @@ -646,6 +660,13 @@ int rConnectClient(Redis *redis, enum redisx_channel channel) { prop_error(fn, config->socketConf(sock, channel)); } +#if WITH_TLS + if(config->tls.enabled && rConnectTLSClient(cp, &config->tls) != X_SUCCESS) { + close(sock); + return x_error(X_NO_INIT, errno, fn, "failed to connect (with TLS) to %s:%hu: %s", redis->id, port, strerror(errno)); + } + else +#endif if(connect(sock, (struct sockaddr *) &serverAddress, sizeof(serverAddress)) < 0) { close(sock); return x_error(X_NO_INIT, errno, fn, "failed to connect to %s:%hu: %s", redis->id, port, strerror(errno)); diff --git a/src/redisx-tls.c b/src/redisx-tls.c new file mode 100644 index 0000000..110b501 --- /dev/null +++ b/src/redisx-tls.c @@ -0,0 +1,262 @@ +/** + * @file + * + * @date Created on Jan 6, 2025 + * @author Attila Kovacs + */ + +#include +#include +#include +#include +#include + +#if WITH_TLS +# include +# include +#endif + +#include "redisx-priv.h" + +#if WITH_TLS +/// \cond PRIVATE + +static int initialized = FALSE; + +/** + * Shuts down SSL and frees up the SSL-related resources on a client. + * + * @param cp Private client data + * + * @sa rConnectTLSClient() + */ +void rDestroyClientTLS(ClientPrivate *cp) { + if(cp->ssl) { + SSL_shutdown(cp->ssl); + cp->ssl = NULL; + } + + if(cp->ctx) { + SSL_CTX_free(cp->ctx); + cp->ctx = NULL; + } +} + +/** + * Connects a client using the specified TLS configuration. + * + * @param cp Private client data + * @param tls TLS configuration + * @return X_SUCCESS (0) if successful, or else an error code <0. + */ +int rConnectTLSClient(ClientPrivate *cp, const TLSConfig *tls) { + static const char *fn = "rConnectClientTLS"; + static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; + + const SSL_METHOD *method; + X509 *server_cert; + + if(!tls->certificate) return x_error(X_NULL, EINVAL, fn, "certificate is NULL"); + + // Initialize SSL lib only once... + pthread_mutex_lock(&mutex); + if(!initialized) { + SSL_library_init(); + SSL_load_error_strings(); + SSLeay_add_ssl_algorithms(); + initialized = TRUE; + } + pthread_mutex_unlock(&mutex); + + method = TLS_client_method(); + cp->ctx = SSL_CTX_new(method); + if (!cp->ctx) { + perror("Unable to create SSL context"); + ERR_print_errors_fp(stderr); + goto abort; // @suppress("Goto statement used") + } + + if(tls->certificate && tls->key) { + /* Set the key and cert */ + if (SSL_CTX_use_certificate_file(cp->ctx, tls->certificate, SSL_FILETYPE_PEM) <= 0) { + ERR_print_errors_fp(stderr); + goto abort; // @suppress("Goto statement used") + } + + if (SSL_CTX_use_PrivateKey_file(cp->ctx, tls->key, SSL_FILETYPE_PEM) <= 0 ) { + ERR_print_errors_fp(stderr); + goto abort; // @suppress("Goto statement used") + } + } + + if(tls->dh_params) if(!SSL_CTX_set_tmp_dh(cp->ctx, tls->dh_params)) + goto abort; // @suppress("Goto statement used") + + SSL_set_fd(cp->ssl, cp->socket); + if(!SSL_connect(cp->ssl)) goto abort; // @suppress("Goto statement used") + + server_cert = SSL_get_peer_certificate(cp->ssl); + if(!server_cert) goto abort; // @suppress("Goto statement used") + + if(redisxIsVerbose()) { + printf("Server certificate: \n"); + char *str = X509_NAME_oneline(X509_get_subject_name(server_cert), 0, 0); + if(str) { + printf("\tsubject: %s\n", str); + OPENSSL_free(str); + } + else printf("\n"); + + str = X509_NAME_oneline(X509_get_issuer_name(server_cert), 0, 0); + if(str) { + printf("\tissuer: %s\n", str); + OPENSSL_free(str); + } + else printf("\n"); + } + + X509_free(server_cert); + + return X_SUCCESS; + + // ------------------------------------------------------------------------- + + abort: + + rDestroyClientTLS(cp); + return x_error(X_FAILURE, errno, fn, "TLS connection failed."); +} + +#endif + +/// \endcond + +/** + * Configures a TLS-enrypted connection to Redis with the specified CA certificate file. Normally you + * will want to set up mutual TLS with redisxSetMutualTLS() also, unless the server is not requiring + * mutual authentication. Additionally, you might also want to set parameters for DH-based cyphers if + * needed using redisxSetDHParams(). + * + * @param redis A Redis instance + * @param ca_file Path to the CA certificate file + * @return X_SUCCESS (0) if successful, or else an error code <0. + * + * @sa redisxSetMutualTLS() + * @sa redisxSetDHParams() + */ +int redisxSetTLS(Redis *redis, const char *ca_file) { + static const char *fn = "redisxSetCA"; + +#if WITH_TLS + RedisPrivate *p; + TLSConfig *tls; + + if(!ca_file) return x_error(X_NULL, EINVAL, fn, "CA file is NULL"); + + if(access(ca_file, R_OK) != 0) return x_error(X_FAILURE, errno, fn, "CA file not readable: %s", ca_file); + + prop_error(fn, rConfigLock(redis)); + + p = (RedisPrivate *) redis->priv; + tls = &p->config.tls; + + tls->enabled = TRUE; + tls->ca_certificate = (char *) ca_file; + + rConfigUnlock(redis); + + return X_SUCCESS; +#else + (void) redis; + (void) ca_file; + + return x_error(X_FAILURE, ENOSYS, fn, "RedisX was built without TLS support"); +#endif +} + +/** + * Set a TLS certificate and private key for mutual TLS. You will still need to call redisxSetTLS() also to create a + * complete TLS configuration. Redis normally uses mutual TLS, which requires both the client and the server to + * authenticate themselves. For this you need the server's TLS certificate and private key also. It is possible to + * configure Redis servers to verify one way only with a CA certificate, in which case you don't need to call this to + * configure the client. + * + * @param redis A Redis instance + * @param cert_file Path to the server's certificate file + * @param key_file Path to the server'sprivate key file + * @return X_SUCCESS (0) if successful, or else an error code <0. + * + * @sa redisxSetTLS() + */ +int redisxSetMutualTLS(Redis *redis, const char *cert_file, const char *key_file) { + static const char *fn = "redisxSetMutualTLS"; + +#if WITH_TLS + RedisPrivate *p; + TLSConfig *tls; + + if(!cert_file || !key_file) return x_error(X_NULL, EINVAL, fn, "Null parameter(s): cert_file=%p, key_file=%p", cert_file, key_file); + + if(access(cert_file, R_OK) != 0) return x_error(X_FAILURE, errno, fn, "Certificate file not readable: %s", cert_file); + if(access(key_file, R_OK) != 0) return x_error(X_FAILURE, errno, fn, "Private key file not readable: %s", key_file); + + prop_error(fn, rConfigLock(redis)); + + p = (RedisPrivate *) redis->priv; + tls = &p->config.tls; + + tls->certificate = (char *) cert_file; + tls->key = (char *) key_file; + + rConfigUnlock(redis); + + return X_SUCCESS; +#else + (void) redis; + (void) cert_file; + (void) key_file; + + return x_error(X_FAILURE, ENOSYS, fn, "RedisX was built without TLS support"); +#endif +} + + +/** + * Sets parameters for DH-based cyphers when using a TLS encrypted connection to Redis. + * + * @param redis A Redis instance + * @param dh_params_file Path to the DH-based cypher parameters file + * @return X_SUCCESS (0) if successful, or else an error code <0. + * + * @sa redisxSetTLS() + */ +int redisxSetDHParams(Redis *redis, const char *dh_params_file) { + static const char *fn = "redisxSetDHParams"; + +#if WITH_TLS + RedisPrivate *p; + TLSConfig *tls; + + if(!dh_params_file) return x_error(X_NULL, EINVAL, fn, "DH parameters file is NULL"); + + if(access(dh_params_file, R_OK) != 0) return x_error(X_FAILURE, errno, fn, "CA file not readable: %s", dh_params_file); + + prop_error(fn, rConfigLock(redis)); + + p = (RedisPrivate *) redis->priv; + tls = &p->config.tls; + + tls->dh_params = (char *) dh_params_file; + + rConfigUnlock(redis); + + return X_SUCCESS; +#else + (void) redis; + (void) dh_params_file; + + return x_error(X_FAILURE, ENOSYS, fn, "RedisX was built without TLS support"); +#endif +} + +