diff --git a/README.md b/README.md index 57a3e9d..b9be0a3 100644 --- a/README.md +++ b/README.md @@ -92,20 +92,22 @@ code (but with different build flags). ``` Options: - -h, --help Show this help message - -m, --method HTTP method to use (default: "GET") [$CHECK_METHOD] - --method-env Change env variable name for --method - -u, --user-agent User-Agent header value (default: "healthcheck/0.0.0 (httpscheck)") [$CHECK_USER_AGENT] - --user-agent-env Change env variable name for --user-agent - -t, --timeout Request timeout in seconds (default: "5") [$CHECK_TIMEOUT] - --timeout-env Change env variable name for --timeout - -H, --header Add custom HTTP header (can be used multiple times) - --basic-auth Basic auth credentials (username:password) [$CHECK_BASIC_AUTH] - --basic-auth-env Change env variable name for --basic-auth - --host Override hostname from URL [$CHECK_HOST] - --host-env Change env variable name for --host - -p, --port Override port from URL [$CHECK_PORT] - --port-env Change env variable name for --port + -h, --help Show this help message + -m, --method HTTP method to use (default: "GET") [$CHECK_METHOD] + --method-env Change env variable name for --method + -u, --user-agent User-Agent header value (default: "healthcheck/0.0.0 (httpcheck)") [$CHECK_USER_AGENT] + --user-agent-env Change env variable name for --user-agent + -t, --timeout Request timeout in seconds (default: "5") [$CHECK_TIMEOUT] + --timeout-env Change env variable name for --timeout + --connect-timeout TCP connect timeout in seconds (float, e.g. 0.5) (default: "0.25") [$CHECK_CONNECT_TIMEOUT] + --connect-timeout-env Change env variable name for --connect-timeout + -H, --header Add custom HTTP header (can be used multiple times) + --basic-auth Basic auth credentials (username:password) [$CHECK_BASIC_AUTH] + --basic-auth-env Change env variable name for --basic-auth + --host Override hostname from URL [$CHECK_HOST] + --host-env Change env variable name for --host + -p, --port Override port from URL [$CHECK_PORT] + --port-env Change env variable name for --port ``` **URL Format Examples:** @@ -692,6 +694,8 @@ To build the tools from sources, ensure you have the following dependencies inst * Standard build tools (`make`, `tar`) * Optionally - `clang-format` +> On Debian: `sudo apt install musl-tools cmake wget patch clang-format make tar` + After cloning the repository, build the tools using the `Makefile` - execute `make`. For testing, you need `python3` and `openssl` installed. Run tests with `make test`. diff --git a/apps/httpcheck.c b/apps/httpcheck.c index 61ba565..d78d9c8 100644 --- a/apps/httpcheck.c +++ b/apps/httpcheck.c @@ -61,6 +61,9 @@ #define MIN_TIMEOUT 1 #define MAX_TIMEOUT 3600 +/* Connect timeout limits */ +#define MIN_CONNECT_TIMEOUT_MS 1 // 0.001 seconds minimum + /* Port range validation */ #define MIN_PORT 1 #define MAX_PORT 65535 @@ -84,6 +87,8 @@ #define FLAG_PORT_SHORT "p" #define FLAG_PORT_LONG "port" #define FLAG_PORT_ENV_LONG "port-env" +#define FLAG_CONNECT_TIMEOUT_LONG "connect-timeout" +#define FLAG_CONNECT_TIMEOUT_ENV_LONG "connect-timeout-env" #define ERR_FAILED_TO_SETUP_SIG_HANDLER "Error: failed to setup signal handler" #define ERR_ALLOCATION_FAILED "Error: memory allocation failed\n" @@ -92,6 +97,7 @@ #define ERR_TOO_MANY_URLS "Error: too many URLs provided (only one allowed)\n" #define ERR_INTERRUPTED "Error: operation interrupted by signal\n" #define ERR_INVALID_TIMEOUT "Error: invalid timeout value\n" +#define ERR_INVALID_CONNECT_TIMEOUT "Error: invalid connect timeout value\n" #define ERR_INVALID_PORT "Error: port must be between 1 and 65535\n" #define ERR_INVALID_HEADER_FORMAT \ "Error: invalid header format (expected 'Name: Value')\n" @@ -232,6 +238,25 @@ static const cli_flag_meta_t TIMEOUT_ENV_FLAG_META = { .type = FLAG_TYPE_STRING, }; +static const cli_flag_meta_t CONNECT_TIMEOUT_FLAG_META = { + .long_name = FLAG_CONNECT_TIMEOUT_LONG, +#ifndef WITH_TLS + .description = "TCP connect timeout in seconds (float, e.g. 0.5)", +#else + .description = + "TCP connect and TLS handshake timeout in seconds (float, e.g. 0.5)", +#endif + .env_variable = "CHECK_CONNECT_TIMEOUT", + .type = FLAG_TYPE_STRING, + .default_value.string_value = "0.25", +}; + +static const cli_flag_meta_t CONNECT_TIMEOUT_ENV_FLAG_META = { + .long_name = FLAG_CONNECT_TIMEOUT_ENV_LONG, + .description = "Change env variable name for --" FLAG_CONNECT_TIMEOUT_LONG, + .type = FLAG_TYPE_STRING, +}; + static const cli_flag_meta_t HEADER_FLAG_META = { .short_name = FLAG_HEADER_SHORT, .long_name = FLAG_HEADER_LONG, @@ -428,7 +453,8 @@ static bool resolve_host(const char *host, struct in_addr *addr) { static request_result_t https_request(const char *method, const http_url_parsed_t *url, const http_headers_t *headers, - const int timeout) { + const int timeout, + const int connect_timeout_ms) { char *host = malloc(url->host_len + 1); if (!host) { return REQUEST_ALLOCATION_ERROR; @@ -479,6 +505,12 @@ static request_result_t https_request(const char *method, // set non-blocking mode const int flags = fcntl(server_fd.fd, F_GETFL, 0); + if (flags < 0) { + result = REQUEST_SOCKET_ERROR; + + goto cleanup; + } + fcntl(server_fd.fd, F_SETFL, flags | O_NONBLOCK); // prepare address @@ -499,7 +531,7 @@ static request_result_t https_request(const char *method, // wait for connection with timeout struct pollfd pfd = {.fd = server_fd.fd, .events = POLLOUT}; - const int poll_result = poll(&pfd, 1, timeout * 1000); + const int poll_result = poll(&pfd, 1, connect_timeout_ms); if (poll_result < 0) { result = (errno == EINTR && interrupted) ? REQUEST_INTERRUPTED_ERROR @@ -515,7 +547,7 @@ static request_result_t https_request(const char *method, } // check for socket errors - int so_error; + int so_error = 0; socklen_t len = sizeof(so_error); getsockopt(server_fd.fd, SOL_SOCKET, SO_ERROR, &so_error, &len); if (so_error != 0) { @@ -531,14 +563,18 @@ static request_result_t https_request(const char *method, goto cleanup; } - // restore blocking mode and set timeouts + // restore blocking mode and set connect timeout for TLS handshake phase fcntl(server_fd.fd, F_SETFL, flags); struct timeval tv; - tv.tv_sec = timeout; - tv.tv_usec = 0; - setsockopt(server_fd.fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); - setsockopt(server_fd.fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + tv.tv_sec = connect_timeout_ms / 1000; + tv.tv_usec = (connect_timeout_ms % 1000) * 1000; + if (setsockopt(server_fd.fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0 || + setsockopt(server_fd.fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)) < 0) { + result = REQUEST_SOCKET_ERROR; + + goto cleanup; + } // setup SSL/TLS configuration ret = mbedtls_ssl_config_defaults(&conf, MBEDTLS_SSL_IS_CLIENT, @@ -570,7 +606,7 @@ static request_result_t https_request(const char *method, mbedtls_ssl_set_bio(&ssl, &server_fd, mbedtls_net_send, mbedtls_net_recv, NULL); - // perform SSL/TLS handshake + // perform SSL/TLS handshake (governed by connect_timeout_ms via SO_RCVTIMEO) while ((ret = mbedtls_ssl_handshake(&ssl)) != 0) { if (ret != MBEDTLS_ERR_SSL_WANT_READ && ret != MBEDTLS_ERR_SSL_WANT_WRITE) { result = (interrupted) ? REQUEST_INTERRUPTED_ERROR : REQUEST_TLS_ERROR; @@ -585,6 +621,16 @@ static request_result_t https_request(const char *method, } } + // handshake done: switch to full timeout for request/response exchange + tv.tv_sec = timeout; + tv.tv_usec = 0; + if (setsockopt(server_fd.fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0 || + setsockopt(server_fd.fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)) < 0) { + result = REQUEST_SOCKET_ERROR; + + goto cleanup; + } + // build HTTP request http_request_build_result_t request = http_build_request(method, url, headers); @@ -713,7 +759,8 @@ static request_result_t https_request(const char *method, static request_result_t http_request(const char *method, const http_url_parsed_t *url, const http_headers_t *headers, - const int timeout) { + const int timeout, + const int connect_timeout_ms) { char *host = malloc(url->host_len + 1); // +1 for null terminator if (!host) { return REQUEST_ALLOCATION_ERROR; @@ -764,6 +811,12 @@ static request_result_t http_request(const char *method, // set non-blocking mode for connect const int flags = fcntl(sockfd, F_GETFL, 0); + if (flags < 0) { + result = REQUEST_SOCKET_ERROR; + + goto cleanup; + } + fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); // establish connection @@ -778,7 +831,7 @@ static request_result_t http_request(const char *method, // wait for connection with timeout struct pollfd pfd = {.fd = sockfd, .events = POLLOUT}; - const int poll_result = poll(&pfd, 1, timeout * 1000); // timeout in ms + const int poll_result = poll(&pfd, 1, connect_timeout_ms); if (poll_result < 0) { result = (errno == EINTR && interrupted) ? REQUEST_INTERRUPTED_ERROR : REQUEST_SOCKET_ERROR; @@ -792,7 +845,7 @@ static request_result_t http_request(const char *method, } // check for socket errors - int so_error; + int so_error = 0; socklen_t len = sizeof(so_error); getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &so_error, &len); if (so_error != 0) { @@ -848,8 +901,8 @@ static request_result_t http_request(const char *method, size_t total_read = 0; while (total_read < 12) { - const ssize_t n = - recv(sockfd, resp_buf + total_read, sizeof(resp_buf) - total_read, 0); + const ssize_t n = recv(sockfd, resp_buf + total_read, + sizeof(resp_buf) - 1 - total_read, 0); if (n < 0) { result = (errno == EINTR && interrupted) ? REQUEST_INTERRUPTED_ERROR @@ -867,6 +920,8 @@ static request_result_t http_request(const char *method, total_read += (size_t)n; } + resp_buf[total_read] = '\0'; // ensure null-terminated before parsing + // parse status code const int status_code = http_get_response_status_code(resp_buf); if (status_code < 0) { @@ -972,6 +1027,73 @@ static int parse_timeout(const char *str) { return result; } +/** + * Parse a connect timeout value from string with validation. + * Accepts decimal values like "0.1", "1", "1.5" (seconds). + * Returns parsed value in milliseconds on success, -1 on error. + */ +static int parse_connect_timeout(const char *str) { + if (str == NULL || *str == '\0') { + return -1; + } + + // parse integer part (seconds) + unsigned long int_part = 0; + const char *p = str; + + while (*p >= '0' && *p <= '9') { + const unsigned long digit = (unsigned long)(*p - '0'); + // check before multiply: 2147482 * 1000 + 999 <= INT_MAX + if (int_part > (2147482UL - digit) / 10UL) { + return -1; + } + int_part = int_part * 10UL + digit; + p++; + } + + // require at least one digit before optional decimal point + if (p == str) { + return -1; + } + + // parse optional fractional part + int frac_ms = 0; + + if (*p == '.') { + p++; + unsigned int frac = 0; + unsigned int divisor = 1; + int frac_digits = 0; + + while (*p >= '0' && *p <= '9') { + if (frac_digits < 4) { // precision up to 0.0001s = 0.1ms + frac = frac * 10U + (unsigned int)(*p - '0'); + divisor *= 10U; + frac_digits++; + } + + p++; + } + + if (frac_digits > 0) { + frac_ms = (int)((frac * 1000U) / divisor); + } + } + + // must have consumed the entire string + if (*p != '\0') { + return -1; + } + + const int total_ms = (int)(int_part * 1000UL + (unsigned long)frac_ms); + + if (total_ms < MIN_CONNECT_TIMEOUT_MS) { + return -1; + } + + return total_ms; +} + /** * Override target flag's env variable name if specified in env_flag. */ @@ -1033,6 +1155,10 @@ int main(const int argc, const char *argv[]) { cli_flag_state_t *timeout_flag = cli_app_add_flag(app, &TIMEOUT_FLAG_META); const cli_flag_state_t *timeout_env_flag = cli_app_add_flag(app, &TIMEOUT_ENV_FLAG_META); + cli_flag_state_t *connect_timeout_flag = + cli_app_add_flag(app, &CONNECT_TIMEOUT_FLAG_META); + const cli_flag_state_t *connect_timeout_env_flag = + cli_app_add_flag(app, &CONNECT_TIMEOUT_ENV_FLAG_META); const cli_flag_state_t *header_flag = cli_app_add_flag(app, &HEADER_FLAG_META); cli_flag_state_t *basic_auth_flag = @@ -1048,7 +1174,8 @@ int main(const int argc, const char *argv[]) { if (!help_flag || !method_flag || !method_env_flag || !user_agent_flag || !user_agent_env_flag || !timeout_flag || !timeout_env_flag || - !header_flag || !basic_auth_flag || !basic_auth_env_flag || !host_flag || + !connect_timeout_flag || !connect_timeout_env_flag || !header_flag || + !basic_auth_flag || !basic_auth_env_flag || !host_flag || !host_env_flag || !port_flag || !port_env_flag) { fputs(ERR_ALLOCATION_FAILED, stderr); @@ -1083,10 +1210,13 @@ int main(const int argc, const char *argv[]) { const struct { const cli_flag_state_t *env_flag; cli_flag_state_t *target_flag; - } flag_pairs[] = { - {method_env_flag, method_flag}, {user_agent_env_flag, user_agent_flag}, - {timeout_env_flag, timeout_flag}, {basic_auth_env_flag, basic_auth_flag}, - {host_env_flag, host_flag}, {port_env_flag, port_flag}}; + } flag_pairs[] = {{method_env_flag, method_flag}, + {user_agent_env_flag, user_agent_flag}, + {timeout_env_flag, timeout_flag}, + {connect_timeout_env_flag, connect_timeout_flag}, + {basic_auth_env_flag, basic_auth_flag}, + {host_env_flag, host_flag}, + {port_env_flag, port_flag}}; for (size_t i = 0; i < sizeof(flag_pairs) / sizeof(flag_pairs[0]); i++) { if (override_flag_env_variable(flag_pairs[i].env_flag, @@ -1160,6 +1290,15 @@ int main(const int argc, const char *argv[]) { goto cleanup; } + // parse and validate connect timeout + int connect_timeout_ms = + parse_connect_timeout(connect_timeout_flag->value.string_value); + if (connect_timeout_ms <= 0) { + fputs(ERR_INVALID_CONNECT_TIMEOUT, stderr); + + goto cleanup; + } + // validate basic auth format and append header if provided if (basic_auth_flag->value.string_value) { if (strchr(basic_auth_flag->value.string_value, ':') == NULL) { @@ -1317,11 +1456,11 @@ int main(const int argc, const char *argv[]) { if (url.proto == PROTO_HTTPS) { // explicit HTTPS result = https_request(method_flag->value.string_value, &url, headers, - timeout_sec); + timeout_sec, connect_timeout_ms); } else if (url.proto == PROTO_HTTP) { // explicit HTTP result = http_request(method_flag->value.string_value, &url, headers, - timeout_sec); + timeout_sec, connect_timeout_ms); } else { // auto-detect: try HTTPS first with port 443, fallback to HTTP with port 80 http_url_parsed_t https_url = url; @@ -1332,7 +1471,7 @@ int main(const int argc, const char *argv[]) { } result = https_request(method_flag->value.string_value, &https_url, headers, - timeout_sec); + timeout_sec, connect_timeout_ms); // fallback to HTTP only on connection/TLS errors (not HTTP status errors) if (result == REQUEST_SOCKET_ERROR || result == REQUEST_TLS_ERROR || @@ -1347,12 +1486,12 @@ int main(const int argc, const char *argv[]) { } result = http_request(method_flag->value.string_value, &http_url, headers, - timeout_sec); + timeout_sec, connect_timeout_ms); } } #else - result = - http_request(method_flag->value.string_value, &url, headers, timeout_sec); + result = http_request(method_flag->value.string_value, &url, headers, + timeout_sec, connect_timeout_ms); #endif switch (result) { diff --git a/tests/feature/httpcheck.py b/tests/feature/httpcheck.py index d04f563..88cedc1 100755 --- a/tests/feature/httpcheck.py +++ b/tests/feature/httpcheck.py @@ -638,6 +638,13 @@ def get_test_cases() -> List[TestCase]: want_exit_code=0, ), + TestCase( + name="Custom connect-timeout environment variable name", + give_args=["--connect-timeout-env", "CONN_TIMEOUT", "{PROTOCOL}://127.0.0.1:{PORT}/"], + give_env={"CONN_TIMEOUT": "0.5"}, + want_exit_code=0, + ), + TestCase( name="Custom basic-auth environment variable name", give_args=["--basic-auth-env", "AUTH_CREDS", "{PROTOCOL}://127.0.0.1:{PORT}/"], @@ -778,6 +785,33 @@ def get_test_cases() -> List[TestCase]: server_delay=0.5, ), + # Connect timeout tests + TestCase( + name="Connect timeout via flag (float seconds)", + give_args=["--connect-timeout", "0.5", "{PROTOCOL}://127.0.0.1:{PORT}/"], + want_exit_code=0, + ), + + TestCase( + name="Connect timeout via flag (integer seconds)", + give_args=["--connect-timeout", "1", "{PROTOCOL}://127.0.0.1:{PORT}/"], + want_exit_code=0, + ), + + TestCase( + name="Connect timeout via environment variable", + give_args=["{PROTOCOL}://127.0.0.1:{PORT}/"], + give_env={"CHECK_CONNECT_TIMEOUT": "0.5"}, + want_exit_code=0, + ), + + TestCase( + name="Connect timeout flag overrides environment variable", + give_args=["--connect-timeout", "0.5", "{PROTOCOL}://127.0.0.1:{PORT}/"], + give_env={"CHECK_CONNECT_TIMEOUT": "abc"}, + want_exit_code=0, + ), + # Error handling TestCase( name="No URL provided", @@ -830,6 +864,41 @@ def get_test_cases() -> List[TestCase]: want_stderr_contains="invalid timeout value", ), + TestCase( + name="Invalid connect timeout value (zero)", + give_args=["--connect-timeout", "0", "{PROTOCOL}://127.0.0.1:{PORT}/"], + want_exit_code=1, + want_stderr_contains="invalid connect timeout value", + ), + + TestCase( + name="Invalid connect timeout value (zero float)", + give_args=["--connect-timeout", "0.0", "{PROTOCOL}://127.0.0.1:{PORT}/"], + want_exit_code=1, + want_stderr_contains="invalid connect timeout value", + ), + + TestCase( + name="Invalid connect timeout value (non-numeric)", + give_args=["--connect-timeout", "abc", "{PROTOCOL}://127.0.0.1:{PORT}/"], + want_exit_code=1, + want_stderr_contains="invalid connect timeout value", + ), + + TestCase( + name="Invalid connect timeout value (negative)", + give_args=["--connect-timeout", "-1", "{PROTOCOL}://127.0.0.1:{PORT}/"], + want_exit_code=1, + want_stderr_contains="invalid connect timeout value", + ), + + TestCase( + name="Invalid connect timeout value (trailing garbage)", + give_args=["--connect-timeout", "0.5abc", "{PROTOCOL}://127.0.0.1:{PORT}/"], + want_exit_code=1, + want_stderr_contains="invalid connect timeout value", + ), + TestCase( name="Invalid port value (too small)", give_args=["--port", "0", "{PROTOCOL}://127.0.0.1:{PORT}/"], @@ -942,6 +1011,7 @@ def get_test_cases() -> List[TestCase]: "--method-env", "M", "--user-agent-env", "UA", "--timeout-env", "TO", + "--connect-timeout-env", "CT", "--host-env", "H", "--port-env", "P", "--basic-auth-env", "BA", @@ -952,6 +1022,7 @@ def get_test_cases() -> List[TestCase]: "M": "POST", "UA": "CustomAgent/1.0", "TO": "10", + "CT": "0.5", "H": "127.0.0.1", "P": "{PORT}", "BA": "testuser:testpass", @@ -1563,6 +1634,23 @@ def get_test_cases() -> List[TestCase]: fallback_only=True, ), + TestCase( + name="Fallback: Explicit connect-timeout preserved during fallback", + give_args=["--connect-timeout", "0.5", "127.0.0.1:{PORT}/health"], + want_exit_code=0, + want_url_path="/health", + fallback_only=True, + ), + + TestCase( + name="Fallback: Connect-timeout via environment preserved during fallback", + give_args=["127.0.0.1:{PORT}/health"], + give_env={"CHECK_CONNECT_TIMEOUT": "0.5"}, + want_exit_code=0, + want_url_path="/health", + fallback_only=True, + ), + # Security tests TestCase( name="Security: CRLF injection in path rejected",