|
// client.c - OpenSSL QUIC handshake-only client (nonblocking, poll, hard timeout) |
|
// Usage: |
|
// ./client [connect_host] [port] [sni_host] [-4|-6] |
|
// Examples: |
|
// ./client |
|
// ./client nghttp2.org 443 nghttp2.org -4 |
|
// ./client 139.162.123.134 443 nghttp2.org |
|
// |
|
// NOTE: This establishes QUIC+TLS with ALPN "h3" only. |
|
// It does NOT implement HTTP/3 request/response framing. |
|
|
|
#include <openssl/ssl.h> |
|
#include <openssl/err.h> |
|
#include <openssl/quic.h> |
|
#include <openssl/x509v3.h> |
|
|
|
#include <poll.h> |
|
#include <stdio.h> |
|
#include <string.h> |
|
#include <unistd.h> |
|
#include <time.h> |
|
|
|
#include <sys/types.h> |
|
#include <sys/socket.h> |
|
#include <netdb.h> |
|
#include <arpa/inet.h> |
|
|
|
static void die_openssl(const char *msg) { |
|
fprintf(stderr, "%s\n", msg); |
|
ERR_print_errors_fp(stderr); |
|
_exit(1); |
|
} |
|
|
|
static long long now_ms(void) { |
|
struct timespec ts; |
|
clock_gettime(CLOCK_MONOTONIC, &ts); |
|
return (long long)ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL; |
|
} |
|
|
|
static int poll_wait(int fd, int timeout_ms) { |
|
struct pollfd p; |
|
memset(&p, 0, sizeof(p)); |
|
p.fd = fd; |
|
p.events = POLLIN | POLLOUT; |
|
return poll(&p, 1, timeout_ms); |
|
} |
|
|
|
static BIO *find_connect_bio(BIO *head) { |
|
// Walk the BIO chain and find the connect BIO reliably |
|
for (BIO *b = head; b != NULL; b = BIO_next(b)) { |
|
if (BIO_method_type(b) == BIO_TYPE_CONNECT) { |
|
return b; |
|
} |
|
} |
|
return NULL; |
|
} |
|
|
|
// Resolve hostname to a numeric IP string (v4 or v6). Returns 1 on success. |
|
static int resolve_ip_literal(const char *host, int family, char *out, size_t out_len) { |
|
struct addrinfo hints, *res = NULL; |
|
memset(&hints, 0, sizeof(hints)); |
|
hints.ai_family = family; // AF_INET or AF_INET6 |
|
hints.ai_socktype = SOCK_DGRAM; // QUIC is UDP |
|
hints.ai_protocol = IPPROTO_UDP; |
|
|
|
int rc = getaddrinfo(host, NULL, &hints, &res); |
|
if (rc != 0 || res == NULL) return 0; |
|
|
|
void *addr = NULL; |
|
if (res->ai_family == AF_INET) { |
|
addr = &((struct sockaddr_in *)res->ai_addr)->sin_addr; |
|
} else if (res->ai_family == AF_INET6) { |
|
addr = &((struct sockaddr_in6 *)res->ai_addr)->sin6_addr; |
|
} else { |
|
freeaddrinfo(res); |
|
return 0; |
|
} |
|
|
|
const char *p = inet_ntop(res->ai_family, addr, out, (socklen_t)out_len); |
|
freeaddrinfo(res); |
|
return p != NULL; |
|
} |
|
|
|
int main(int argc, char **argv) { |
|
const char *connect_host = "nghttp2.org"; |
|
const char *port = "443"; |
|
const char *sni_host = NULL; // default to connect_host |
|
|
|
int force_v4 = 0; |
|
int force_v6 = 0; |
|
|
|
if (argc >= 2) connect_host = argv[1]; |
|
if (argc >= 3) port = argv[2]; |
|
if (argc >= 4) sni_host = argv[3]; |
|
if (argc >= 5) { |
|
if (strcmp(argv[4], "-4") == 0) force_v4 = 1; |
|
else if (strcmp(argv[4], "-6") == 0) force_v6 = 1; |
|
} |
|
if (!sni_host) sni_host = connect_host; |
|
|
|
// If forced, resolve to IP literal to avoid OpenSSL connect BIO family controls (which are fragile). |
|
char resolved_ip[INET6_ADDRSTRLEN]; |
|
const char *connect_target = connect_host; |
|
const char *family_str = "auto"; |
|
|
|
if (force_v4) { |
|
family_str = "IPv4"; |
|
if (resolve_ip_literal(connect_host, AF_INET, resolved_ip, sizeof(resolved_ip))) { |
|
connect_target = resolved_ip; |
|
} else { |
|
fprintf(stderr, "resolve IPv4 failed for host='%s'\n", connect_host); |
|
_exit(2); |
|
} |
|
} else if (force_v6) { |
|
family_str = "IPv6"; |
|
if (resolve_ip_literal(connect_host, AF_INET6, resolved_ip, sizeof(resolved_ip))) { |
|
connect_target = resolved_ip; |
|
} else { |
|
fprintf(stderr, "resolve IPv6 failed for host='%s'\n", connect_host); |
|
_exit(2); |
|
} |
|
} |
|
|
|
SSL_library_init(); |
|
SSL_load_error_strings(); |
|
|
|
SSL_CTX *ctx = SSL_CTX_new(OSSL_QUIC_client_method()); |
|
if (!ctx) die_openssl("SSL_CTX_new(OSSL_QUIC_client_method) failed"); |
|
|
|
SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL); |
|
if (SSL_CTX_set_default_verify_paths(ctx) != 1) |
|
die_openssl("SSL_CTX_set_default_verify_paths failed"); |
|
|
|
if (SSL_CTX_load_verify_locations(ctx, "/etc/ssl/certs/ca-certificates.crt", NULL) != 1) { |
|
die_openssl("SSL_CTX_load_verify_locations(/etc/ssl/certs/ca-certificates.crt) failed"); |
|
} |
|
|
|
BIO *bio = BIO_new_ssl_connect(ctx); |
|
if (!bio) die_openssl("BIO_new_ssl_connect failed"); |
|
|
|
// Find the real connect BIO in the chain (don’t assume BIO_next once) |
|
BIO *cbio = find_connect_bio(bio); |
|
if (!cbio) die_openssl("connect BIO not found in BIO chain"); |
|
|
|
// Nonblocking on both |
|
BIO_set_nbio(bio, 1); |
|
BIO_set_nbio(cbio, 1); |
|
|
|
char hostport[256]; |
|
snprintf(hostport, sizeof(hostport), "%s:%s", connect_target, port); |
|
|
|
// Set host:port on CONNECT BIO ONLY |
|
if (BIO_set_conn_hostname(cbio, hostport) != 1) |
|
die_openssl("BIO_set_conn_hostname(connect_bio) failed"); |
|
|
|
fprintf(stderr, "connect='%s' sni='%s' family=%s\n", hostport, sni_host, family_str); |
|
|
|
SSL *ssl = NULL; |
|
BIO_get_ssl(bio, &ssl); |
|
if (!ssl) die_openssl("BIO_get_ssl failed"); |
|
|
|
// SNI + hostname verification uses sni_host (keep hostname even if connecting to an IP) |
|
if (SSL_set_tlsext_host_name(ssl, sni_host) != 1) |
|
die_openssl("SSL_set_tlsext_host_name failed"); |
|
if (SSL_set1_host(ssl, sni_host) != 1) |
|
die_openssl("SSL_set1_host failed"); |
|
|
|
// QUIC requires ALPN; for HTTP/3: "h3" (wire format) |
|
static const unsigned char alpn_h3[] = { 0x02, 'h', '3' }; |
|
if (SSL_set_alpn_protos(ssl, alpn_h3, sizeof(alpn_h3)) != 0) |
|
die_openssl("SSL_set_alpn_protos(h3) failed"); |
|
|
|
const int total_timeout_ms = 5000; |
|
const int step_ms = 200; |
|
long long start = now_ms(); |
|
|
|
// Step 1: obtain FD from connect BIO (may appear after progress) |
|
int fd = -1; |
|
while (1) { |
|
BIO_get_fd(cbio, &fd); |
|
if (fd >= 0) break; |
|
|
|
int cr = BIO_do_connect(cbio); |
|
if (cr > 0) continue; |
|
|
|
long verr = SSL_get_verify_result(ssl); |
|
fprintf(stderr, "verify_result=%ld (%s)\n", verr, X509_verify_cert_error_string(verr)); |
|
|
|
X509 *cert = SSL_get0_peer_certificate(ssl); |
|
if (cert) { |
|
char subj[256]; |
|
X509_NAME_oneline(X509_get_subject_name(cert), subj, sizeof(subj)); |
|
fprintf(stderr, "peer_subject=%s\n", subj); |
|
|
|
// Hostname check (nghttp2.org) |
|
int ok = X509_check_host(cert, sni_host, 0, 0, NULL); |
|
fprintf(stderr, "X509_check_host(%s)=%d\n", sni_host, ok); |
|
} |
|
|
|
if (!BIO_should_retry(cbio)) { |
|
die_openssl("BIO_do_connect(connect_bio) failed (no retry)"); |
|
} |
|
|
|
if (now_ms() - start >= total_timeout_ms) { |
|
fprintf(stderr, "timeout: could not obtain UDP socket fd in %d ms\n", total_timeout_ms); |
|
_exit(3); |
|
} |
|
|
|
usleep(step_ms * 1000); |
|
} |
|
|
|
// Step 2: drive full QUIC+TLS handshake via chain head |
|
while (1) { |
|
int r = BIO_do_connect(bio); |
|
if (r > 0) break; |
|
|
|
if (!BIO_should_retry(bio)) |
|
die_openssl("BIO_do_connect(bio) failed (no retry)"); |
|
|
|
if (now_ms() - start >= total_timeout_ms) { |
|
fprintf(stderr, "timeout: QUIC connect/handshake not completed in %d ms\n", total_timeout_ms); |
|
_exit(4); |
|
} |
|
|
|
int pr = poll_wait(fd, step_ms); |
|
if (pr < 0) die_openssl("poll failed"); |
|
} |
|
|
|
fprintf(stderr, "connected (QUIC channel started)\n"); |
|
|
|
const unsigned char *sel = NULL; |
|
unsigned int sel_len = 0; |
|
SSL_get0_alpn_selected(ssl, &sel, &sel_len); |
|
if (sel && sel_len > 0) |
|
fprintf(stderr, "ALPN selected: %.*s\n", (int)sel_len, (const char *)sel); |
|
else |
|
fprintf(stderr, "ALPN selected: (none)\n"); |
|
|
|
BIO_free_all(bio); |
|
SSL_CTX_free(ctx); |
|
return 0; |
|
} |