diff --git a/include/ProxySQL_Admin_Tables_Definitions.h b/include/ProxySQL_Admin_Tables_Definitions.h index 7214f3dcd..30b2105cc 100644 --- a/include/ProxySQL_Admin_Tables_Definitions.h +++ b/include/ProxySQL_Admin_Tables_Definitions.h @@ -178,6 +178,13 @@ #define STATS_SQLITE_TABLE_TSDB "CREATE TABLE stats_tsdb (Variable_Name VARCHAR NOT NULL PRIMARY KEY , Variable_Value VARCHAR NOT NULL)" +/** + * @brief Table for global ProxySQL statistics that are not specific to MySQL or PgSQL. + * @details Populated at query time by stats___global(). Contains metrics such as TLS load + * status, certificate file paths, and future cross-protocol statistics. + */ +#define STATS_SQLITE_TABLE_GLOBAL "CREATE TABLE stats_proxysql_global (Variable_Name VARCHAR NOT NULL PRIMARY KEY , Variable_Value VARCHAR NOT NULL)" + #define STATS_SQLITE_TABLE_MEMORY_METRICS "CREATE TABLE stats_memory_metrics (Variable_Name VARCHAR NOT NULL PRIMARY KEY , Variable_Value VARCHAR NOT NULL)" #define STATS_SQLITE_TABLE_MYSQL_GTID_EXECUTED "CREATE TABLE stats_mysql_gtid_executed (hostname VARCHAR NOT NULL , port INT NOT NULL DEFAULT 3306 , gtid_executed VARCHAR , events INT NOT NULL)" @@ -188,6 +195,8 @@ #define STATS_SQLITE_TABLE_MYSQL_CLIENT_HOST_CACHE "CREATE TABLE stats_mysql_client_host_cache (client_address VARCHAR NOT NULL , error_count INT NOT NULL , last_updated BIGINT NOT NULL)" #define STATS_SQLITE_TABLE_MYSQL_CLIENT_HOST_CACHE_RESET "CREATE TABLE stats_mysql_client_host_cache_reset (client_address VARCHAR NOT NULL , error_count INT NOT NULL , last_updated BIGINT NOT NULL)" +#define STATS_SQLITE_TABLE_TLS_CERTIFICATES "CREATE TABLE stats_tls_certificates (cert_type VARCHAR NOT NULL PRIMARY KEY , file_path VARCHAR NOT NULL , subject_cn VARCHAR , issuer_cn VARCHAR , serial_number VARCHAR , not_before VARCHAR , not_after VARCHAR , days_until_expiry INT , sha256_fingerprint VARCHAR , loaded_at INT NOT NULL DEFAULT 0)" + #ifdef DEBUG #define ADMIN_SQLITE_TABLE_DEBUG_LEVELS "CREATE TABLE debug_levels (module VARCHAR NOT NULL PRIMARY KEY , verbosity INT NOT NULL DEFAULT 0)" #define ADMIN_SQLITE_TABLE_DEBUG_FILTERS "CREATE TABLE debug_filters (filename VARCHAR NOT NULL , line INT NOT NULL , funct VARCHAR NOT NULL , PRIMARY KEY (filename, line, funct) )" diff --git a/include/proxysql_admin.h b/include/proxysql_admin.h index 42e371533..eaf2b67b9 100644 --- a/include/proxysql_admin.h +++ b/include/proxysql_admin.h @@ -808,6 +808,8 @@ class ProxySQL_Admin { void stats___mysql_prepared_statements_info(); void stats___mysql_gtid_executed(); void stats___mysql_client_host_cache(bool reset); + void stats___tls_certificates(); + void stats___proxysql_global(); #ifdef PROXYSQLGENAI void stats___mcp_query_tools_counters(bool reset); diff --git a/include/proxysql_glovars.hpp b/include/proxysql_glovars.hpp index 86d038af6..7624368a1 100644 --- a/include/proxysql_glovars.hpp +++ b/include/proxysql_glovars.hpp @@ -141,6 +141,12 @@ class ProxySQL_GlobalVariables { pthread_mutex_t ext_glomth_mutex; pthread_mutex_t ext_glopth_mutex; bool ssl_keylog_enabled; + uint64_t tls_load_count; + time_t tls_last_load_timestamp; + bool tls_last_load_ok; + char *tls_cert_file; + char *tls_ca_file; + char *tls_key_file; } global; struct mysql { char *server_version; diff --git a/lib/Admin_Bootstrap.cpp b/lib/Admin_Bootstrap.cpp index cf3135dfe..c22e7075b 100644 --- a/lib/Admin_Bootstrap.cpp +++ b/lib/Admin_Bootstrap.cpp @@ -893,6 +893,8 @@ bool ProxySQL_Admin::init(const bootstrap_info_t& bootstrap_info) { insert_into_tables_defs(tables_defs_stats,"stats_mysql_client_host_cache", STATS_SQLITE_TABLE_MYSQL_CLIENT_HOST_CACHE); insert_into_tables_defs(tables_defs_stats,"stats_mysql_client_host_cache_reset", STATS_SQLITE_TABLE_MYSQL_CLIENT_HOST_CACHE_RESET); insert_into_tables_defs(tables_defs_stats,"stats_mysql_query_events", ADMIN_SQLITE_TABLE_STATS_MYSQL_QUERY_EVENTS); + insert_into_tables_defs(tables_defs_stats,"stats_tls_certificates", STATS_SQLITE_TABLE_TLS_CERTIFICATES); + insert_into_tables_defs(tables_defs_stats,"stats_proxysql_global", STATS_SQLITE_TABLE_GLOBAL); insert_into_tables_defs(tables_defs_stats,"stats_pgsql_global", STATS_SQLITE_TABLE_PGSQL_GLOBAL); insert_into_tables_defs(tables_defs_stats,"stats_pgsql_connection_pool", STATS_SQLITE_TABLE_PGSQL_CONNECTION_POOL); diff --git a/lib/ProxySQL_Admin.cpp b/lib/ProxySQL_Admin.cpp index e89bb6175..82bccb41b 100644 --- a/lib/ProxySQL_Admin.cpp +++ b/lib/ProxySQL_Admin.cpp @@ -1262,6 +1262,8 @@ bool ProxySQL_Admin::GenericRefreshStatistics(const char *query_no_space, unsign bool stats_mysql_client_host_cache_reset=false; bool stats_pgsql_client_host_cache = false; bool stats_pgsql_client_host_cache_reset = false; + bool stats_tls_certificates=false; + bool stats_proxysql_global=false; bool dump_global_variables=false; bool runtime_scheduler=false; @@ -1446,6 +1448,10 @@ bool ProxySQL_Admin::GenericRefreshStatistics(const char *query_no_space, unsign { stats_pgsql_client_host_cache = true; refresh = true; } if (strstr(query_no_space, "stats_pgsql_client_host_cache_reset")) { stats_pgsql_client_host_cache_reset = true; refresh = true; } + if (strstr(query_no_space,"stats_tls_certificates")) + { stats_tls_certificates=true; refresh=true; } + if (strstr(query_no_space,"stats_proxysql_global")) + { stats_proxysql_global=true; refresh=true; } if (strstr(query_no_space,"stats_proxysql_servers_checksums")) { stats_proxysql_servers_checksums = true; refresh = true; } if (strstr(query_no_space,"stats_proxysql_servers_metrics")) @@ -1705,6 +1711,12 @@ bool ProxySQL_Admin::GenericRefreshStatistics(const char *query_no_space, unsign if (stats_pgsql_client_host_cache_reset) { stats___pgsql_client_host_cache(true); } + if (stats_tls_certificates) { + stats___tls_certificates(); + } + if (stats_proxysql_global) { + stats___proxysql_global(); + } #ifdef PROXYSQLGENAI if (stats_mcp_query_tools_counters) { stats___mcp_query_tools_counters(false); diff --git a/lib/ProxySQL_Admin_Stats.cpp b/lib/ProxySQL_Admin_Stats.cpp index 2f6a6e4a2..ee763ae19 100644 --- a/lib/ProxySQL_Admin_Stats.cpp +++ b/lib/ProxySQL_Admin_Stats.cpp @@ -25,6 +25,7 @@ #include "Query_Tool_Handler.h" #include "RAG_Tool_Handler.h" #endif /* PROXYSQLGENAI */ +#include #define SAFE_SQLITE3_STEP(_stmt) do {\ do {\ @@ -522,6 +523,18 @@ const void sqlite3_global_stats_row_step( rc = (*proxy_sqlite3_reset)(stmt); ASSERT_SQLITE_OK(rc, db); }; +static void sqlite3_global_stats_row_step_str( + SQLite3DB* db, sqlite3_stmt* stmt, const char* name, const char* val +) { + int rc = (*proxy_sqlite3_bind_text)(stmt, 1, name, -1, SQLITE_TRANSIENT); + ASSERT_SQLITE_OK(rc, db); + rc = (*proxy_sqlite3_bind_text)(stmt, 2, val ? val : "", -1, SQLITE_TRANSIENT); + ASSERT_SQLITE_OK(rc, db); + SAFE_SQLITE3_STEP2(stmt); + rc = (*proxy_sqlite3_clear_bindings)(stmt); ASSERT_SQLITE_OK(rc, db); + rc = (*proxy_sqlite3_reset)(stmt); ASSERT_SQLITE_OK(rc, db); +}; + void ProxySQL_Admin::stats___mysql_global() { if (!GloMTH) return; SQLite3_result * resultset=GloMTH->SQL3_GlobalStatus(true); @@ -615,7 +628,6 @@ void ProxySQL_Admin::stats___mysql_global() { sqlite3_global_stats_row_step(statsdb, row_stmt, "mysql_listener_paused", admin_proxysql_mysql_paused); sqlite3_global_stats_row_step(statsdb, row_stmt, "OpenSSL_Version_Num", OpenSSL_version_num()); - if (GloMyLogger != nullptr) { const string prefix = "MySQL_Logger_"; std::unordered_map metrics = GloMyLogger->getAllMetrics(); @@ -804,6 +816,48 @@ void ProxySQL_Admin::stats___pgsql_global() { statsdb->execute("COMMIT"); } +/** + * @brief Populates the `stats_proxysql_global` table with ProxySQL-wide metrics + * that are not specific to the MySQL or PgSQL protocol. + * + * @details This function is called at query time whenever the stats_proxysql_global table + * is accessed (e.g. "SELECT * FROM stats.stats_proxysql_global"). It deletes all existing + * rows and reinserts fresh values, ensuring `TLS_Last_Load_Timestamp` and other + * time-sensitive data are always current. + * + * Currently tracked variables: + * - TLS_Load_Count : Number of times TLS has been loaded or reloaded. + * - TLS_Last_Load_Timestamp : Unix timestamp of the most recent successful TLS load. + * - TLS_Last_Load_Result : "NONE", "SUCCESS", or "FAILED" depending on last load outcome. + * - TLS_Server_Cert_File : Path to the server TLS certificate file. + * - TLS_CA_Cert_File : Path to the CA certificate file. + * - TLS_Key_File : Path to the private key file. + */ +void ProxySQL_Admin::stats___proxysql_global() { + statsdb->execute("BEGIN"); + statsdb->execute("DELETE FROM stats_proxysql_global"); + + const string q_row_insert { "INSERT INTO stats_proxysql_global VALUES (?1, ?2)" }; + int rc = 0; + stmt_unique_ptr u_row_stmt { nullptr }; + std::tie(rc, u_row_stmt) = statsdb->prepare_v2(q_row_insert.c_str()); + ASSERT_SQLITE_OK(rc, statsdb); + sqlite3_stmt *row_stmt = u_row_stmt.get(); + + { + std::lock_guard lock(GloVars.global.ssl_mutex); + sqlite3_global_stats_row_step(statsdb, row_stmt, "TLS_Load_Count", GloVars.global.tls_load_count); + sqlite3_global_stats_row_step(statsdb, row_stmt, "TLS_Last_Load_Timestamp", (unsigned long long)GloVars.global.tls_last_load_timestamp); + const char *tls_result = GloVars.global.tls_load_count == 0 ? "NONE" : (GloVars.global.tls_last_load_ok ? "SUCCESS" : "FAILED"); + sqlite3_global_stats_row_step_str(statsdb, row_stmt, "TLS_Last_Load_Result", tls_result); + sqlite3_global_stats_row_step_str(statsdb, row_stmt, "TLS_Server_Cert_File", GloVars.global.tls_cert_file ? GloVars.global.tls_cert_file : ""); + sqlite3_global_stats_row_step_str(statsdb, row_stmt, "TLS_CA_Cert_File", GloVars.global.tls_ca_file ? GloVars.global.tls_ca_file : ""); + sqlite3_global_stats_row_step_str(statsdb, row_stmt, "TLS_Key_File", GloVars.global.tls_key_file ? GloVars.global.tls_key_file : ""); + } + + statsdb->execute("COMMIT"); +} + #ifdef PROXYSQLTSDB void ProxySQL_Admin::stats___tsdb() { if (!GloProxyStats) return; @@ -2841,3 +2895,147 @@ void ProxySQL_Admin::stats___mcp_query_rules() { delete resultset; } #endif /* PROXYSQLGENAI */ + +// Helper: convert ASN1_TIME to ISO 8601 string (YYYY-MM-DDTHH:MM:SSZ) +static std::string asn1_time_to_iso8601(const ASN1_TIME *asn1t) { + if (!asn1t) return ""; + struct tm t = {}; + if (!ASN1_TIME_to_tm(asn1t, &t)) return ""; + char buf[32] = {}; + strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &t); + return std::string(buf); +} + +// Helper: get CN from X509_NAME +static std::string x509_name_cn(X509_NAME *name) { + if (!name) return ""; + char buf[1024] = {}; + if (X509_NAME_get_text_by_NID(name, NID_commonName, buf, sizeof(buf)) < 0) + return ""; + return std::string(buf); +} + +// Helper: get hex serial number from X509 +static std::string x509_serial_hex(X509 *cert) { + ASN1_INTEGER *serial = X509_get_serialNumber(cert); + if (!serial) return ""; + BIGNUM *bn = ASN1_INTEGER_to_BN(serial, NULL); + if (!bn) return ""; + char *hex = BN_bn2hex(bn); + std::string result = hex ? std::string(hex) : ""; + OPENSSL_free(hex); + BN_free(bn); + return result; +} + +// Helper: get SHA-256 fingerprint hex string from X509 +static std::string x509_sha256_fingerprint(X509 *cert) { + unsigned char md[EVP_MAX_MD_SIZE] = {}; + unsigned int len = 0; + if (!X509_digest(cert, EVP_sha256(), md, &len)) return ""; + std::string result; + result.reserve(len * 2); + char hex_byte[3] = {}; + for (unsigned int i = 0; i < len; i++) { + snprintf(hex_byte, sizeof(hex_byte), "%02X", md[i]); + result += hex_byte; + } + return result; +} + +// Helper: insert one certificate row into stats_tls_certificates +static void insert_tls_cert_row( + SQLite3DB *statsdb, sqlite3_stmt *stmt, + const char *cert_type, const char *file_path, + X509 *cert, time_t loaded_at +) { + std::string subject_cn = x509_name_cn(X509_get_subject_name(cert)); + std::string issuer_cn = x509_name_cn(X509_get_issuer_name(cert)); + std::string serial_num = x509_serial_hex(cert); + std::string not_before = asn1_time_to_iso8601(X509_get0_notBefore(cert)); + std::string not_after = asn1_time_to_iso8601(X509_get0_notAfter(cert)); + std::string fingerprint = x509_sha256_fingerprint(cert); + + // Calculate days_until_expiry at query time + // pday = full days from now to not_after (negative if expired) + int pday = 0; + { + int psec = 0; + if (!ASN1_TIME_diff(&pday, &psec, NULL, X509_get0_notAfter(cert))) { + pday = 0; // on error, default to 0 (treat as expiring today) + } + } + int days_until_expiry = pday; + + int rc = (*proxy_sqlite3_bind_text)(stmt, 1, cert_type, -1, SQLITE_TRANSIENT); // cert_type + ASSERT_SQLITE_OK(rc, statsdb); + rc = (*proxy_sqlite3_bind_text)(stmt, 2, file_path, -1, SQLITE_TRANSIENT); // file_path + ASSERT_SQLITE_OK(rc, statsdb); + rc = (*proxy_sqlite3_bind_text)(stmt, 3, subject_cn.c_str(), -1, SQLITE_TRANSIENT); // subject_cn + ASSERT_SQLITE_OK(rc, statsdb); + rc = (*proxy_sqlite3_bind_text)(stmt, 4, issuer_cn.c_str(), -1, SQLITE_TRANSIENT); // issuer_cn + ASSERT_SQLITE_OK(rc, statsdb); + rc = (*proxy_sqlite3_bind_text)(stmt, 5, serial_num.c_str(), -1, SQLITE_TRANSIENT); // serial_number + ASSERT_SQLITE_OK(rc, statsdb); + rc = (*proxy_sqlite3_bind_text)(stmt, 6, not_before.c_str(), -1, SQLITE_TRANSIENT); // not_before + ASSERT_SQLITE_OK(rc, statsdb); + rc = (*proxy_sqlite3_bind_text)(stmt, 7, not_after.c_str(), -1, SQLITE_TRANSIENT); // not_after + ASSERT_SQLITE_OK(rc, statsdb); + rc = (*proxy_sqlite3_bind_int)(stmt, 8, days_until_expiry); // days_until_expiry + ASSERT_SQLITE_OK(rc, statsdb); + rc = (*proxy_sqlite3_bind_text)(stmt, 9, fingerprint.c_str(), -1, SQLITE_TRANSIENT); // sha256_fingerprint + ASSERT_SQLITE_OK(rc, statsdb); + rc = (*proxy_sqlite3_bind_int64)(stmt, 10, (sqlite3_int64)loaded_at); // loaded_at + ASSERT_SQLITE_OK(rc, statsdb); + + SAFE_SQLITE3_STEP2(stmt); + rc = (*proxy_sqlite3_clear_bindings)(stmt); ASSERT_SQLITE_OK(rc, statsdb); + rc = (*proxy_sqlite3_reset)(stmt); ASSERT_SQLITE_OK(rc, statsdb); +} + +void ProxySQL_Admin::stats___tls_certificates() { + statsdb->execute("BEGIN"); + statsdb->execute("DELETE FROM stats_tls_certificates"); + + // Copy cert file paths and tracking info under ssl_mutex to avoid races + char *cert_file = NULL; + char *ca_file = NULL; + time_t loaded_at = 0; + + { + std::lock_guard lock(GloVars.global.ssl_mutex); + if (GloVars.global.tls_cert_file) + cert_file = strdup(GloVars.global.tls_cert_file); + if (GloVars.global.tls_ca_file) + ca_file = strdup(GloVars.global.tls_ca_file); + loaded_at = GloVars.global.tls_last_load_timestamp; + } + + const char *insert_q = + "INSERT INTO stats_tls_certificates VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)"; + int rc = 0; + stmt_unique_ptr u_stmt { nullptr }; + std::tie(rc, u_stmt) = statsdb->prepare_v2(insert_q); + ASSERT_SQLITE_OK(rc, statsdb); + sqlite3_stmt *stmt = u_stmt.get(); + + // Helper lambda: read a PEM cert from file and insert a row into stats_tls_certificates + auto process_cert = [&](const char *cert_type, char *file_path) { + if (!file_path) return; + BIO *bio = BIO_new_file(file_path, "r"); + if (bio) { + X509 *cert = PEM_read_bio_X509(bio, NULL, NULL, NULL); + BIO_free_all(bio); + if (cert) { + insert_tls_cert_row(statsdb, stmt, cert_type, file_path, cert, loaded_at); + X509_free(cert); + } + } + free(file_path); + }; + + process_cert("server", cert_file); + process_cert("ca", ca_file); + + statsdb->execute("COMMIT"); +} diff --git a/lib/ProxySQL_GloVars.cpp b/lib/ProxySQL_GloVars.cpp index 8a5eb8b92..e6f271246 100644 --- a/lib/ProxySQL_GloVars.cpp +++ b/lib/ProxySQL_GloVars.cpp @@ -170,6 +170,18 @@ ProxySQL_GlobalVariables::~ProxySQL_GlobalVariables() { free(global.gr_bootstrap_ssl_mode); global.gr_bootstrap_ssl_mode = nullptr; } + if (global.tls_cert_file) { + free(global.tls_cert_file); + global.tls_cert_file = nullptr; + } + if (global.tls_ca_file) { + free(global.tls_ca_file); + global.tls_ca_file = nullptr; + } + if (global.tls_key_file) { + free(global.tls_key_file); + global.tls_key_file = nullptr; + } }; ProxySQL_GlobalVariables::ProxySQL_GlobalVariables() : @@ -246,6 +258,12 @@ ProxySQL_GlobalVariables::ProxySQL_GlobalVariables() : global.gr_bootstrap_ssl_key = nullptr; global.gr_bootstrap_ssl_mode = nullptr; global.ssl_keylog_enabled = false; + global.tls_load_count = 0; + global.tls_last_load_timestamp = 0; + global.tls_last_load_ok = false; + global.tls_cert_file = NULL; + global.tls_ca_file = NULL; + global.tls_key_file = NULL; opt = new ez::ezOptionParser(); opt->overview = "High Performance Advanced Proxy for MySQL"; opt->syntax = "proxysql [OPTIONS]"; diff --git a/src/proxy_tls.cpp b/src/proxy_tls.cpp index 4f79eba07..38acc553f 100644 --- a/src/proxy_tls.cpp +++ b/src/proxy_tls.cpp @@ -406,6 +406,20 @@ int ProxySQL_create_or_load_TLS(bool bootstrap, std::string& msg) { // clients (MySQL > 8.0.29) attempt session reuses during reconnect operations. SSL_CTX_set_options(GloVars.global.ssl_ctx, SSL_OP_NO_TICKET); SSL_CTX_set_session_cache_mode(GloVars.global.ssl_ctx, SSL_SESS_CACHE_OFF); + + // Store TLS file paths and tracking info for stats table + { + std::lock_guard lock(GloVars.global.ssl_mutex); + free(GloVars.global.tls_key_file); + GloVars.global.tls_key_file = ssl_key_fp ? strdup(ssl_key_fp) : NULL; + free(GloVars.global.tls_cert_file); + GloVars.global.tls_cert_file = ssl_cert_fp ? strdup(ssl_cert_fp) : NULL; + free(GloVars.global.tls_ca_file); + GloVars.global.tls_ca_file = ssl_ca_fp ? strdup(ssl_ca_fp) : NULL; + GloVars.global.tls_load_count++; + GloVars.global.tls_last_load_timestamp = time(NULL); + GloVars.global.tls_last_load_ok = true; + } } else { // here we use global.tmp_ssl_ctx instead of global.ssl_ctx // because we will try to swap at the end @@ -425,6 +439,16 @@ int ProxySQL_create_or_load_TLS(bool bootstrap, std::string& msg) { free(GloVars.global.ssl_cert_pem_mem); GloVars.global.ssl_key_pem_mem = load_file(ssl_key_fp); GloVars.global.ssl_cert_pem_mem = load_file(ssl_cert_fp); + // Update TLS tracking fields for stats table (under ssl_mutex) + free(GloVars.global.tls_key_file); + GloVars.global.tls_key_file = ssl_key_fp ? strdup(ssl_key_fp) : NULL; + free(GloVars.global.tls_cert_file); + GloVars.global.tls_cert_file = ssl_cert_fp ? strdup(ssl_cert_fp) : NULL; + free(GloVars.global.tls_ca_file); + GloVars.global.tls_ca_file = ssl_ca_fp ? strdup(ssl_ca_fp) : NULL; + GloVars.global.tls_load_count++; + GloVars.global.tls_last_load_timestamp = time(NULL); + GloVars.global.tls_last_load_ok = true; } else { proxy_error("Failed to load location of CA certificates for verification\n"); @@ -461,6 +485,12 @@ int ProxySQL_create_or_load_TLS(bool bootstrap, std::string& msg) { // Completely disable session tickets and session-cache. See comment above. SSL_CTX_set_options(GloVars.global.ssl_ctx, SSL_OP_NO_TICKET); SSL_CTX_set_session_cache_mode(GloVars.global.ssl_ctx, SSL_SESS_CACHE_OFF); + } else if (!bootstrap) { + // Record reload failure in TLS tracking stats + std::lock_guard lock(GloVars.global.ssl_mutex); + GloVars.global.tls_load_count++; + GloVars.global.tls_last_load_timestamp = time(NULL); + GloVars.global.tls_last_load_ok = false; } X509_free(x509); EVP_PKEY_free(pkey); diff --git a/test/tap/groups/groups.json b/test/tap/groups/groups.json index ddc0764e9..184efa138 100644 --- a/test/tap/groups/groups.json +++ b/test/tap/groups/groups.json @@ -312,6 +312,7 @@ "test_ssl_large_query-2-t" : [ "legacy-g4","mysql84-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "test_stats_proxysql_message_metrics-t" : [ "legacy-g4","mysql84-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "test_thread_conn_dist-t" : [ "legacy-g4","mysql84-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], + "test_tls_stats-t" : [ "legacy-g4","mysql84-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "test_throttle_max_bytes_per_second_to_client-t" : [ "legacy-g4","mysql84-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "test_tsdb_api-t" : [ "ai-g1" ], "test_tsdb_variables-t" : [ "ai-g1" ], diff --git a/test/tap/tests/test_tls_stats-t.cpp b/test/tap/tests/test_tls_stats-t.cpp new file mode 100644 index 000000000..68ebe6137 --- /dev/null +++ b/test/tap/tests/test_tls_stats-t.cpp @@ -0,0 +1,344 @@ +/** + * @file test_tls_stats-t.cpp + * @brief TAP test for stats_tls_certificates and stats_proxysql_global TLS metrics. + * + * @details This test verifies: + * 1. stats_proxysql_global table exists and is queryable from the stats schema. + * 2. stats_proxysql_global contains the expected TLS tracking variables: + * - TLS_Load_Count + * - TLS_Last_Load_Timestamp + * - TLS_Last_Load_Result + * - TLS_Server_Cert_File + * - TLS_CA_Cert_File + * - TLS_Key_File + * 3. TLS_Load_Count >= 1 (ProxySQL always loads certs at startup). + * 4. TLS_Last_Load_Timestamp is a positive Unix timestamp. + * 5. TLS_Last_Load_Result is one of "NONE", "SUCCESS", or "FAILED". + * 6. TLS_Server_Cert_File and TLS_CA_Cert_File are non-empty strings. + * 7. stats_tls_certificates table exists and is queryable. + * 8. stats_tls_certificates has exactly two rows (cert_type 'server' and 'ca'). + * 9. Both rows have non-empty file_path, subject_cn, issuer_cn, serial_number, + * not_before, not_after, sha256_fingerprint. + * 10. days_until_expiry is a reasonable value (> -36500 and < 36500). + * 11. loaded_at is a positive Unix timestamp. + * 12. After PROXYSQL RELOAD TLS: + * - TLS_Load_Count in stats_proxysql_global increments by 1. + * - stats_tls_certificates rows are still present and non-empty. + * 13. TLS_Last_Load_Timestamp in stats_proxysql_global increases after PROXYSQL RELOAD TLS. + * 14. TLS-related variables are NOT present in stats_mysql_global. + */ + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "mysql.h" + +#include "tap.h" +#include "command_line.h" +#include "utils.h" + +using std::map; +using std::set; +using std::string; +using std::vector; + +/** @brief Maximum plausible lifetime of a certificate in days (approx. 100 years). */ +static const int MAX_CERT_DAYS = 36500; + +/** + * @brief Executes a query and returns all rows as a map keyed by the first column. + * @param conn An open MySQL connection. + * @param query The SQL query to run (must return exactly 2 columns). + * @return A map where key=col[0] and value=col[1]. + */ +static map query_key_value(MYSQL* conn, const char* query) { + map result; + if (mysql_query(conn, query)) { + diag("Query failed: '%s' err='%s'", query, mysql_error(conn)); + return result; + } + MYSQL_RES* res = mysql_store_result(conn); + if (!res) return result; + MYSQL_ROW row; + while ((row = mysql_fetch_row(res))) { + if (row[0] && row[1]) + result[string(row[0])] = string(row[1]); + } + mysql_free_result(res); + return result; +} + +/** + * @brief Executes a query and returns all rows as a vector of key-value maps. + * @param conn An open MySQL connection. + * @param query The SQL query to run. + * @param col_names Column names to use as map keys (in order). + * @return A vector of maps, one per result row. + */ +static vector> query_rows(MYSQL* conn, const char* query, const vector& col_names) { + vector> rows; + if (mysql_query(conn, query)) { + diag("Query failed: '%s' err='%s'", query, mysql_error(conn)); + return rows; + } + MYSQL_RES* res = mysql_store_result(conn); + if (!res) return rows; + MYSQL_ROW row; + while ((row = mysql_fetch_row(res))) { + map r; + for (size_t i = 0; i < col_names.size(); i++) { + r[col_names[i]] = row[i] ? string(row[i]) : ""; + } + rows.push_back(r); + } + mysql_free_result(res); + return rows; +} + +int main(int argc, char** argv) { + CommandLine cl; + + diag("TAP test for SSL/TLS Certificate Statistics Table"); + diag("This test verifies that ProxySQL correctly reports TLS certificate information,"); + diag("tracks TLS load operations in stats_proxysql_global, and updates these stats after a reload."); + + if (cl.getEnv()) { + diag("Failed to get the required environmental variables."); + return exit_status(); + } + + // Tests: + // 1-5: stats_proxysql_global TLS variables present + // 6-9: stats_proxysql_global TLS variable values + // 10-11: TLS_Last_Load_Result valid value + // 12-16: stats_tls_certificates structure + // 17-24: stats_tls_certificates row data for 'server' and 'ca' + // 25-28: PROXYSQL RELOAD TLS increments counter and updates timestamp + // 29: TLS vars NOT in stats_mysql_global + plan(29); + + diag("Connecting to ProxySQL Admin interface on %s:%d", cl.host, cl.admin_port); + MYSQL* admin = mysql_init(NULL); + if (!admin) { + fprintf(stderr, "File %s, line %d, Error: %s\n", __FILE__, __LINE__, mysql_error(admin)); + return exit_status(); + } + + if (!mysql_real_connect(admin, cl.host, cl.admin_username, cl.admin_password, NULL, cl.admin_port, NULL, 0)) { + fprintf(stderr, "File %s, line %d, Error: %s\n", __FILE__, __LINE__, mysql_error(admin)); + return exit_status(); + } + + // ----------------------------------------------------------------------- + // Part 1: stats_proxysql_global - TLS tracking variables + // ----------------------------------------------------------------------- + diag("Step 1: Verifying TLS tracking variables in stats.stats_proxysql_global"); + diag("--- Querying stats.stats_proxysql_global ---"); + + auto global_stats = query_key_value(admin, + "SELECT Variable_Name, Variable_Value FROM stats.stats_proxysql_global"); + + const vector expected_tls_vars = { + "TLS_Load_Count", + "TLS_Last_Load_Timestamp", + "TLS_Last_Load_Result", + "TLS_Server_Cert_File", + "TLS_CA_Cert_File", + "TLS_Key_File", + }; + + for (const auto& var : expected_tls_vars) { + ok(global_stats.count(var) > 0, + "stats_proxysql_global: Variable '%s' is present", var.c_str()); + } + + diag("Verifying values of TLS tracking variables..."); + // TLS_Load_Count >= 1 + int tls_load_count = 0; + if (global_stats.count("TLS_Load_Count")) + tls_load_count = std::stoi(global_stats["TLS_Load_Count"]); + ok(tls_load_count >= 1, + "stats_proxysql_global: TLS_Load_Count is >= 1 (was %d)", tls_load_count); + + // TLS_Last_Load_Timestamp is a positive Unix timestamp (> 0) + long long tls_ts = 0; + if (global_stats.count("TLS_Last_Load_Timestamp")) + tls_ts = std::stoll(global_stats["TLS_Last_Load_Timestamp"]); + ok(tls_ts > 0, + "stats_proxysql_global: TLS_Last_Load_Timestamp is positive (was %lld)", tls_ts); + + // TLS_Last_Load_Result must be one of NONE, SUCCESS, FAILED + string tls_result = global_stats.count("TLS_Last_Load_Result") ? + global_stats["TLS_Last_Load_Result"] : ""; + ok(tls_result == "SUCCESS" || tls_result == "NONE" || tls_result == "FAILED", + "stats_proxysql_global: TLS_Last_Load_Result='%s' is valid (NONE/SUCCESS/FAILED)", + tls_result.c_str()); + + // TLS_Server_Cert_File should be a non-empty path + string cert_file = global_stats.count("TLS_Server_Cert_File") ? + global_stats["TLS_Server_Cert_File"] : ""; + ok(!cert_file.empty(), + "stats_proxysql_global: TLS_Server_Cert_File is non-empty ('%s')", cert_file.c_str()); + + // TLS_CA_Cert_File should be a non-empty path + string ca_file = global_stats.count("TLS_CA_Cert_File") ? + global_stats["TLS_CA_Cert_File"] : ""; + ok(!ca_file.empty(), + "stats_proxysql_global: TLS_CA_Cert_File is non-empty ('%s')", ca_file.c_str()); + + // ----------------------------------------------------------------------- + // Part 2: stats_tls_certificates - table structure and row content + // ----------------------------------------------------------------------- + diag("Step 2: Verifying content of stats.stats_tls_certificates"); + diag("--- Querying stats.stats_tls_certificates ---"); + + const vector cert_cols = { + "cert_type", "file_path", "subject_cn", "issuer_cn", + "serial_number", "not_before", "not_after", + "days_until_expiry", "sha256_fingerprint", "loaded_at" + }; + + auto cert_rows = query_rows(admin, + "SELECT cert_type, file_path, subject_cn, issuer_cn, serial_number, " + "not_before, not_after, days_until_expiry, sha256_fingerprint, loaded_at " + "FROM stats.stats_tls_certificates", + cert_cols); + + // Should have exactly 2 rows: 'server' and 'ca' + ok(cert_rows.size() == 2, + "stats_tls_certificates: has exactly 2 rows (got %zu)", cert_rows.size()); + + // Find the 'server' and 'ca' rows + map server_row, ca_row; + for (const auto& r : cert_rows) { + if (r.at("cert_type") == "server") server_row = r; + if (r.at("cert_type") == "ca") ca_row = r; + } + + diag("Validating cert_type 'server' and 'ca' rows exist..."); + ok(!server_row.empty(), "stats_tls_certificates: row with cert_type='server' exists"); + ok(!ca_row.empty(), "stats_tls_certificates: row with cert_type='ca' exists"); + + // Check non-empty required fields for server cert + diag("Validating server certificate row data..."); + if (!server_row.empty()) { + ok(!server_row["file_path"].empty(), + "stats_tls_certificates: server file_path is non-empty ('%s')", + server_row["file_path"].c_str()); + ok(!server_row["sha256_fingerprint"].empty(), + "stats_tls_certificates: server sha256_fingerprint is non-empty"); + int server_days = std::stoi(server_row["days_until_expiry"]); + ok(server_days > -MAX_CERT_DAYS && server_days < MAX_CERT_DAYS, + "stats_tls_certificates: server days_until_expiry=%d is reasonable", + server_days); + long long server_loaded_at = std::stoll(server_row["loaded_at"]); + ok(server_loaded_at > 0, + "stats_tls_certificates: server loaded_at=%lld is positive", + server_loaded_at); + } else { + ok(false, "stats_tls_certificates: server file_path is non-empty (row missing)"); + ok(false, "stats_tls_certificates: server sha256_fingerprint is non-empty (row missing)"); + ok(false, "stats_tls_certificates: server days_until_expiry is reasonable (row missing)"); + ok(false, "stats_tls_certificates: server loaded_at is positive (row missing)"); + } + + // Check non-empty required fields for CA cert + diag("Validating CA certificate row data..."); + if (!ca_row.empty()) { + ok(!ca_row["file_path"].empty(), + "stats_tls_certificates: ca file_path is non-empty ('%s')", + ca_row["file_path"].c_str()); + ok(!ca_row["sha256_fingerprint"].empty(), + "stats_tls_certificates: ca sha256_fingerprint is non-empty"); + int ca_days = std::stoi(ca_row["days_until_expiry"]); + ok(ca_days > -MAX_CERT_DAYS && ca_days < MAX_CERT_DAYS, + "stats_tls_certificates: ca days_until_expiry=%d is reasonable", + ca_days); + long long ca_loaded_at = std::stoll(ca_row["loaded_at"]); + ok(ca_loaded_at > 0, + "stats_tls_certificates: ca loaded_at=%lld is positive", + ca_loaded_at); + } else { + ok(false, "stats_tls_certificates: ca file_path is non-empty (row missing)"); + ok(false, "stats_tls_certificates: ca sha256_fingerprint is non-empty (row missing)"); + ok(false, "stats_tls_certificates: ca days_until_expiry is reasonable (row missing)"); + ok(false, "stats_tls_certificates: ca loaded_at is positive (row missing)"); + } + + // ----------------------------------------------------------------------- + // Part 3: PROXYSQL RELOAD TLS increments TLS_Load_Count and updates timestamp + // ----------------------------------------------------------------------- + diag("Step 3: Verifying PROXYSQL RELOAD TLS updates stats"); + diag("--- Executing PROXYSQL RELOAD TLS ---"); + + // Sleep 1 second to guarantee timestamp changes on fast systems + sleep(1); + + if (mysql_query(admin, "PROXYSQL RELOAD TLS")) { + diag("PROXYSQL RELOAD TLS failed: %s", mysql_error(admin)); + } + mysql_free_result(mysql_store_result(admin)); + + diag("Verifying updated stats in stats.stats_proxysql_global after reload..."); + auto global_stats_after = query_key_value(admin, + "SELECT Variable_Name, Variable_Value FROM stats.stats_proxysql_global"); + + int tls_load_count_after = 0; + if (global_stats_after.count("TLS_Load_Count")) + tls_load_count_after = std::stoi(global_stats_after["TLS_Load_Count"]); + ok(tls_load_count_after == tls_load_count + 1, + "stats_proxysql_global: TLS_Load_Count incremented after RELOAD TLS (%d -> %d)", + tls_load_count, tls_load_count_after); + + long long tls_ts_after = 0; + if (global_stats_after.count("TLS_Last_Load_Timestamp")) + tls_ts_after = std::stoll(global_stats_after["TLS_Last_Load_Timestamp"]); + ok(tls_ts_after > tls_ts, + "stats_proxysql_global: TLS_Last_Load_Timestamp increased after RELOAD TLS (%lld -> %lld)", + tls_ts, tls_ts_after); + + string tls_result_after = global_stats_after.count("TLS_Last_Load_Result") ? + global_stats_after["TLS_Last_Load_Result"] : ""; + ok(tls_result_after == "SUCCESS", + "stats_proxysql_global: TLS_Last_Load_Result='SUCCESS' after RELOAD TLS (got '%s')", + tls_result_after.c_str()); + + diag("Verifying stats.stats_tls_certificates rows after reload..."); + // stats_tls_certificates rows still present after reload + auto cert_rows_after = query_rows(admin, + "SELECT cert_type, file_path, sha256_fingerprint FROM stats.stats_tls_certificates", + {"cert_type", "file_path", "sha256_fingerprint"}); + ok(cert_rows_after.size() == 2, + "stats_tls_certificates: still has 2 rows after RELOAD TLS (got %zu)", + cert_rows_after.size()); + + // ----------------------------------------------------------------------- + // Part 4: TLS variables must NOT appear in stats_mysql_global + // ----------------------------------------------------------------------- + diag("Step 4: Verifying TLS variables are absent from stats.stats_mysql_global"); + diag("--- Querying stats.stats_mysql_global ---"); + + auto mysql_global_stats = query_key_value(admin, + "SELECT Variable_Name, Variable_Value FROM stats.stats_mysql_global"); + + bool tls_vars_in_mysql_global = false; + for (const auto& var : expected_tls_vars) { + if (mysql_global_stats.count(var)) { + diag("UNEXPECTED: TLS variable '%s' found in stats_mysql_global", var.c_str()); + tls_vars_in_mysql_global = true; + } + } + ok(!tls_vars_in_mysql_global, + "TLS variables are NOT present in stats_mysql_global"); + + diag("Test completed successfully, closing connection."); + mysql_close(admin); + return exit_status(); +}