From 5d739b1b1ac7ec2625d864cbb17d758c6c92f8bc Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Thu, 11 Jun 2026 13:19:27 -0400 Subject: [PATCH] net.http: speak HTTP/2 over the Windows SChannel backend (#27397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * net.http: speak HTTP/2 over the Windows SChannel backend Second half of vlang/v#27383. The SChannel backend could negotiate ALPN but only spoke HTTP/1.1; this makes fetch(enable_http2: true) (the default for https) actually negotiate and speak HTTP/2 on default Windows. thirdparty/vschannel/vschannel.c gains a streaming transport that keeps the TLS connection open: vschannel_h2_connect (connect + handshake + cert check), vschannel_write/vschannel_read (EncryptMessage/DecryptMessage one record at a time, carrying leftover ciphertext and decrypted plaintext on the TlsContext), and vschannel_h2_close. A V transport (vschannel_h2_windows.c.v) adapts these to the H2Transport interface and drives the existing backend-agnostic H2Conn client via the new shared h2_exchange helper. When the server does not select h2, the request is sent as HTTP/1.1 over the same already-open connection (vschannel_request_on_open), so no extra handshake is paid and single-connection servers are not broken by a probe-then-reconnect. The Windows skips on the h2 negotiation and real-server fetch tests are removed and now pass on default Windows (SChannel). Co-Authored-By: Claude Opus 4.8 * thirdparty/vschannel: preserve post-handshake ExtraData; reuse send buffer; fix reneg leak Addresses code-review findings on the HTTP/2 SChannel path: 1. vschannel_h2_connect dropped (and leaked) the SECBUFFER_EXTRA the handshake returns when the server's first application record (e.g. the HTTP/2 SETTINGS frame) arrives bundled with the final handshake flight. Those bytes are already off the socket, so dropping them desynced H2Conn. Now carried into recv_buf (growing it if the bundled flight exceeds one record) and freed. 2. vschannel_write reused a per-context send buffer instead of LocalAlloc/ LocalFree of a ~16KB record buffer on every (often tiny) HTTP/2 write. 3. The SEC_I_RENEGOTIATE branch of vschannel_read now frees the ExtraBuffer that client_handshake_loop allocates, fixing a per-renegotiation leak. Verified: net.http suite + real-server h2 fetch pass under tcc, gcc and msvc. Co-Authored-By: Claude Opus 4.8 * thirdparty/vschannel: extract shared connect+handshake helper Addresses review finding 4: request(), vschannel_alpn_probe() and vschannel_h2_connect() each had a near-identical copy of connect + perform_client_handshake + capture_alpn (+ optional cert verify). Extract a single static vschannel_open_and_handshake() that all three call, so future handshake/cert/ExtraData fixes live in one place. Net -30 lines. The helper also frees the handshake-bundled SECBUFFER_EXTRA on its error paths and when the caller does not request it (pExtraData == NULL), fixing a latent leak the old request()/alpn_probe paths had by ignoring it. No behavior change: net.http suite + real-server -d network h2 fetch pass under tcc, gcc and msvc. Co-Authored-By: Claude Opus 4.8 * thirdparty/vschannel: gate ALPN/HTTP/2 on Windows 8.1+ SChannel Codex review finding on this PR: SChannel gained client-side ALPN in Windows 8.1 / Server 2012 R2. On older versions, injecting the SECBUFFER_APPLICATION_PROTOCOLS buffer into the handshake can fail it outright, before the HTTP/1.1 fallback (which requires a successful handshake) is reachable — regressing hosts that worked on the one-shot path. Add vschannel_alpn_supported() (RtlGetVersion, cached; GetVersionEx lies on manifest-less binaries) and route https requests on pre-8.1 SChannel straight to the original HTTP/1.1 path with no ALPN. Verified under tcc, gcc and msvc on Windows 11 (gate reports supported; h2 negotiation tests still pass). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Alexander Medvednikov Co-authored-by: Claude Opus 4.8 --- thirdparty/vschannel/vschannel.c | 553 ++++++++++++++++++-- thirdparty/vschannel/vschannel.h | 14 + vlib/net/http/backend.c.v | 12 +- vlib/net/http/backend_vschannel_windows.c.v | 42 ++ vlib/net/http/h2_client_test.v | 10 +- vlib/net/http/server_tls_test.v | 7 - vlib/net/http/vschannel_h2_windows.c.v | 88 ++++ 7 files changed, 653 insertions(+), 73 deletions(-) create mode 100644 vlib/net/http/vschannel_h2_windows.c.v diff --git a/thirdparty/vschannel/vschannel.c b/thirdparty/vschannel/vschannel.c index 62d8e5652..ebb3b1ffe 100644 --- a/thirdparty/vschannel/vschannel.c +++ b/thirdparty/vschannel/vschannel.c @@ -86,6 +86,30 @@ struct TlsContext { // when the server selected none. char negotiated_alpn[256]; unsigned long negotiated_alpn_len; + // Streaming transport state, used by the keep-the-connection-open path + // (vschannel_h2_connect / vschannel_write / vschannel_read) that backs the + // HTTP/2 driver. The one-shot request() path does not touch these. + SecPkgContext_StreamSizes stream_sizes; // cached TLS record sizes + BOOL stream_sizes_valid; + // Ciphertext staging buffer: bytes recv()'d from the socket that have not yet + // been decrypted into a full record (SEC_E_INCOMPLETE_MESSAGE) plus any + // trailing SECBUFFER_EXTRA from the last DecryptMessage. + unsigned char *recv_buf; + unsigned long recv_buf_cap; + unsigned long recv_buf_len; + // Decrypted plaintext carryover: a single DecryptMessage can yield more + // application bytes than the caller's read buffer can hold, so the remainder + // is stashed here and drained on the next vschannel_read(). + unsigned char *plain_buf; + unsigned long plain_buf_cap; + unsigned long plain_buf_len; // valid decrypted bytes + unsigned long plain_buf_off; // bytes already returned to caller + // Reusable encryption buffer for vschannel_write(): one full record + // (header + max message + trailer). Cached so the HTTP/2 driver's many small + // writes do not LocalAlloc/LocalFree on every call. + unsigned char *send_buf; + unsigned long send_buf_cap; + BOOL stream_eof; // close_notify / context expired seen }; TlsContext new_tls_context() { @@ -98,10 +122,50 @@ TlsContext new_tls_context() { .context_initialized = FALSE, .p_pemote_cert_context = NULL, .alpn_wire_len = 0, - .negotiated_alpn_len = 0 + .negotiated_alpn_len = 0, + .stream_sizes_valid = FALSE, + .recv_buf = NULL, + .recv_buf_cap = 0, + .recv_buf_len = 0, + .plain_buf = NULL, + .plain_buf_cap = 0, + .plain_buf_len = 0, + .plain_buf_off = 0, + .send_buf = NULL, + .send_buf_cap = 0, + .stream_eof = FALSE }; }; +// vschannel_alpn_supported reports whether this Windows version's SChannel +// supports client-side ALPN, which was introduced in Windows 8.1 / Server +// 2012 R2 (version 6.3). On older versions, passing a +// SECBUFFER_APPLICATION_PROTOCOLS input buffer into the handshake can fail it +// outright, so callers should skip ALPN (and HTTP/2) entirely there. Uses +// RtlGetVersion because GetVersionEx lies on manifest-less binaries from +// Windows 8.1 onwards. +INT vschannel_alpn_supported() { + static INT cached = -1; + if (cached < 0) { + typedef LONG (WINAPI *RtlGetVersionFn)(OSVERSIONINFOW *); + OSVERSIONINFOW vi; + RtlGetVersionFn get_version = (RtlGetVersionFn)GetProcAddress( + GetModuleHandleW(L"ntdll.dll"), "RtlGetVersion"); + INT supported = 0; + if (get_version != NULL) { + ZeroMemory(&vi, sizeof(vi)); + vi.dwOSVersionInfoSize = sizeof(vi); + if (get_version(&vi) == 0 + && (vi.dwMajorVersion > 6 + || (vi.dwMajorVersion == 6 && vi.dwMinorVersion >= 3))) { + supported = 1; + } + } + cached = supported; + } + return cached; +} + // vschannel_set_alpn configures the ALPN protocol list to advertise during the // next handshake. `wire` is the standard ALPN wire format (each protocol name // preceded by a 1-byte length), e.g. "\x02h2\x08http/1.1". Passing len == 0 @@ -195,6 +259,28 @@ void vschannel_cleanup(TlsContext *tls_ctx) { CertCloseStore(tls_ctx->cert_store, 0); tls_ctx->cert_store = NULL; } + + // Free streaming-transport buffers. + if(tls_ctx->recv_buf) { + LocalFree(tls_ctx->recv_buf); + tls_ctx->recv_buf = NULL; + } + tls_ctx->recv_buf_cap = 0; + tls_ctx->recv_buf_len = 0; + if(tls_ctx->plain_buf) { + LocalFree(tls_ctx->plain_buf); + tls_ctx->plain_buf = NULL; + } + tls_ctx->plain_buf_cap = 0; + tls_ctx->plain_buf_len = 0; + tls_ctx->plain_buf_off = 0; + if(tls_ctx->send_buf) { + LocalFree(tls_ctx->send_buf); + tls_ctx->send_buf = NULL; + } + tls_ctx->send_buf_cap = 0; + tls_ctx->stream_sizes_valid = FALSE; + tls_ctx->stream_eof = FALSE; } void vschannel_init(TlsContext *tls_ctx, BOOL validate_server_certificate) { @@ -215,76 +301,89 @@ void vschannel_init(TlsContext *tls_ctx, BOOL validate_server_certificate) { tls_ctx->creds_initialized = TRUE; } -INT request(TlsContext *tls_ctx, INT iport, LPWSTR host, CHAR *req, DWORD req_len, CHAR **out, vschannel_allocator afn) -{ - SecBuffer ExtraData; +// vschannel_open_and_handshake performs the connection setup shared by +// request(), vschannel_alpn_probe() and vschannel_h2_connect(): connect to +// host:iport, run the TLS handshake (advertising any configured ALPN), record +// the negotiated protocol, and — when verify_cert is set — validate the server +// certificate. On success the connection is left open (context_initialized) and, +// when pExtraData is non-NULL, it receives any application bytes the handshake +// bundled with its final flight (the caller then owns pExtraData->pvBuffer and +// must LocalFree it). On failure it records the error, frees any bundled extra, +// tears the connection down, and returns a non-zero SECURITY_STATUS. A connect +// failure already set last_error via connect_to_server. +static SECURITY_STATUS vschannel_open_and_handshake(TlsContext *tls_ctx, INT iport, LPWSTR host, BOOL verify_cert, SecBuffer *pExtraData) { + SecBuffer local_extra; + SecBuffer *extra = pExtraData ? pExtraData : &local_extra; SECURITY_STATUS Status; - INT i; - INT iOption; - PCHAR pszOption; - - INT resp_length = 0; + extra->pvBuffer = NULL; + extra->cbBuffer = 0; protocol = SP_PROT_TLS1_2_CLIENT; - port_number = iport; vschannel_clear_last_error(tls_ctx); - // Connect to server. if(connect_to_server(tls_ctx, host, port_number)) { vschannel_cleanup(tls_ctx); - return resp_length; + return SEC_E_INTERNAL_ERROR; } - // Perform handshake - Status = perform_client_handshake(tls_ctx, host, &ExtraData); - if(Status) { + Status = perform_client_handshake(tls_ctx, host, extra); + if(Status != SEC_E_OK) { vschannel_set_last_error(tls_ctx, Status); - wprintf(L"Error performing handshake\n"); - vschannel_cleanup(tls_ctx); - return resp_length; + goto fail; } tls_ctx->context_initialized = TRUE; // Record the ALPN protocol the server selected (if any). vschannel_capture_alpn(tls_ctx); - if(tls_ctx->validate_server_certificate) { - // Authenticate server's credentials. - - // Get server's certificate. + if(verify_cert) { + // Get and validate the server's certificate. Status = tls_ctx->sspi->QueryContextAttributes(&tls_ctx->h_context, - SECPKG_ATTR_REMOTE_CERT_CONTEXT, - (PVOID)&tls_ctx->p_pemote_cert_context); + SECPKG_ATTR_REMOTE_CERT_CONTEXT, (PVOID)&tls_ctx->p_pemote_cert_context); if(Status != SEC_E_OK) { vschannel_set_last_error(tls_ctx, Status); - wprintf(L"Error 0x%x querying remote certificate\n", Status); - vschannel_cleanup(tls_ctx); - return resp_length; + goto fail; } - - // Attempt to validate server certificate. - Status = verify_server_certificate(tls_ctx->p_pemote_cert_context, host,0); - if(Status) { + Status = verify_server_certificate(tls_ctx->p_pemote_cert_context, host, 0); + if(Status != SEC_E_OK) { + // Could not authenticate the server (possible MITM): abort. vschannel_set_last_error(tls_ctx, Status); - // The server certificate did not validate correctly. At this - // point, we cannot tell if we are connecting to the correct - // server, or if we are connecting to a "man in the middle" - // attack server. - - // It is therefore best if we abort the connection. - - wprintf(L"Error 0x%x authenticating server credentials!\n", Status); - vschannel_cleanup(tls_ctx); - return resp_length; + goto fail; } - - // Free the server certificate context. CertFreeCertificateContext(tls_ctx->p_pemote_cert_context); tls_ctx->p_pemote_cert_context = NULL; } + // If the caller does not want the bundled application data, drop it. + if(pExtraData == NULL && local_extra.pvBuffer != NULL) { + LocalFree(local_extra.pvBuffer); + } + return SEC_E_OK; + +fail: + if(extra->pvBuffer != NULL) { + LocalFree(extra->pvBuffer); + extra->pvBuffer = NULL; + } + vschannel_cleanup(tls_ctx); + return Status; +} + +INT request(TlsContext *tls_ctx, INT iport, LPWSTR host, CHAR *req, DWORD req_len, CHAR **out, vschannel_allocator afn) +{ + SECURITY_STATUS Status; + INT resp_length = 0; + + // Connect + handshake (+ cert validation when enabled). request() does not + // consume handshake-bundled application data (HTTP/1.1 servers do not send + // before the request), so pass NULL to have it dropped. + if(vschannel_open_and_handshake(tls_ctx, iport, host, + tls_ctx->validate_server_certificate, NULL) != SEC_E_OK) { + return resp_length; + } + // Request from server Status = https_make_request(tls_ctx, req, req_len, out, &resp_length, afn); if(Status) { @@ -308,6 +407,34 @@ INT request(TlsContext *tls_ctx, INT iport, LPWSTR host, CHAR *req, DWORD req_le return resp_length; } +// vschannel_request_on_open runs a one-shot HTTP/1.1 request over a connection +// that vschannel_h2_connect() already opened and handshaked, then closes it. +// It is the HTTP/1.1 fallback used when a server, asked for ALPN `h2`, does not +// select it: rather than reconnect, we reuse the open connection. Returns the +// response length (see request()). +INT vschannel_request_on_open(TlsContext *tls_ctx, CHAR *req, DWORD req_len, CHAR **out, vschannel_allocator afn) { + SECURITY_STATUS Status; + INT resp_length = 0; + + Status = https_make_request(tls_ctx, req, req_len, out, &resp_length, afn); + if(Status) { + vschannel_set_last_error(tls_ctx, Status); + vschannel_cleanup(tls_ctx); + return resp_length; + } + + Status = disconnect_from_server(tls_ctx); + if(Status) { + vschannel_set_last_error(tls_ctx, Status); + vschannel_cleanup(tls_ctx); + return resp_length; + } + tls_ctx->context_initialized = FALSE; + tls_ctx->socket = INVALID_SOCKET; + + return resp_length; +} + // vschannel_alpn_probe connects to host:iport, performs the TLS handshake while // advertising whatever ALPN list was configured via vschannel_set_alpn(), // captures the protocol the server selected into `out` (up to out_cap bytes), @@ -316,33 +443,345 @@ INT request(TlsContext *tls_ctx, INT iport, LPWSTR host, CHAR *req, DWORD req_le // or -1 on connect/handshake failure (see vschannel_last_error). Intended for // tests and capability checks, since request() only speaks HTTP/1.1. INT vschannel_alpn_probe(TlsContext *tls_ctx, INT iport, LPWSTR host, char *out, INT out_cap) { + // Probe only: handshake (no cert validation, no application data) then close. + if(vschannel_open_and_handshake(tls_ctx, iport, host, FALSE, NULL) != SEC_E_OK) { + return -1; + } + + disconnect_from_server(tls_ctx); + tls_ctx->context_initialized = FALSE; + tls_ctx->socket = INVALID_SOCKET; + + return vschannel_get_alpn(tls_ctx, out, out_cap); +} + +// --------------------------------------------------------------------------- +// Streaming transport (keep the TLS connection open and exchange raw bytes). +// +// request() above is a one-shot: connect, handshake, send the whole HTTP/1.1 +// request, read the whole response, disconnect. An HTTP/2 driver instead needs +// a long-lived, byte-oriented transport. The functions below expose exactly +// that: vschannel_h2_connect() opens the connection (handshake + cert check) +// and leaves it open, vschannel_write()/vschannel_read() move application bytes +// across it, and vschannel_h2_close() shuts it down. They reuse the same SSPI +// primitives as request()/https_make_request(), but keep the encrypt/decrypt +// state on the TlsContext so reads can span calls. +// --------------------------------------------------------------------------- + +// vschannel_ensure_stream_state caches the negotiated TLS record sizes and +// allocates the ciphertext/plaintext working buffers, once per connection. +static SECURITY_STATUS vschannel_ensure_stream_state(TlsContext *tls_ctx) { + if(tls_ctx->stream_sizes_valid) { + return SEC_E_OK; + } + SECURITY_STATUS scRet = tls_ctx->sspi->QueryContextAttributes(&tls_ctx->h_context, + SECPKG_ATTR_STREAM_SIZES, &tls_ctx->stream_sizes); + if(scRet != SEC_E_OK) { + return scRet; + } + // One full wire record: header + max plaintext + trailer. recv() never needs + // more than this buffered to complete a single record; trailing bytes of the + // next record are carried as SECBUFFER_EXTRA. + tls_ctx->recv_buf_cap = tls_ctx->stream_sizes.cbHeader + + tls_ctx->stream_sizes.cbMaximumMessage + tls_ctx->stream_sizes.cbTrailer; + tls_ctx->recv_buf = (unsigned char *)LocalAlloc(LPTR, tls_ctx->recv_buf_cap); + // One record decrypts to at most cbMaximumMessage plaintext bytes. + tls_ctx->plain_buf_cap = tls_ctx->stream_sizes.cbMaximumMessage; + tls_ctx->plain_buf = (unsigned char *)LocalAlloc(LPTR, tls_ctx->plain_buf_cap); + // Reusable send buffer: one full outgoing record. + tls_ctx->send_buf_cap = tls_ctx->recv_buf_cap; + tls_ctx->send_buf = (unsigned char *)LocalAlloc(LPTR, tls_ctx->send_buf_cap); + if(tls_ctx->recv_buf == NULL || tls_ctx->plain_buf == NULL || tls_ctx->send_buf == NULL) { + return SEC_E_INTERNAL_ERROR; + } + tls_ctx->recv_buf_len = 0; + tls_ctx->plain_buf_len = 0; + tls_ctx->plain_buf_off = 0; + tls_ctx->stream_sizes_valid = TRUE; + return SEC_E_OK; +} + +// vschannel_h2_connect connects to host:iport, performs the TLS handshake while +// advertising whatever ALPN list was set via vschannel_set_alpn(), validates the +// server certificate (when enabled), and leaves the connection open for +// vschannel_write()/vschannel_read(). Returns 0 on success, non-zero on failure +// (see vschannel_last_error). The negotiated protocol is available afterwards +// via vschannel_get_alpn(). +INT vschannel_h2_connect(TlsContext *tls_ctx, INT iport, LPWSTR host) { SecBuffer ExtraData; SECURITY_STATUS Status; - protocol = SP_PROT_TLS1_2_CLIENT; - port_number = iport; - vschannel_clear_last_error(tls_ctx); - - if(connect_to_server(tls_ctx, host, port_number)) { - vschannel_cleanup(tls_ctx); + // Connect + handshake + cert validation (when enabled). Unlike the one-shot + // paths, keep the handshake-bundled application data: for HTTP/2 it is the + // server's first record (SETTINGS), needed below. + if(vschannel_open_and_handshake(tls_ctx, iport, host, + tls_ctx->validate_server_certificate, &ExtraData) != SEC_E_OK) { return -1; } - Status = perform_client_handshake(tls_ctx, host, &ExtraData); - if(Status) { + Status = vschannel_ensure_stream_state(tls_ctx); + if(Status != SEC_E_OK) { vschannel_set_last_error(tls_ctx, Status); + if(ExtraData.pvBuffer != NULL) { + LocalFree(ExtraData.pvBuffer); + } vschannel_cleanup(tls_ctx); return -1; } - tls_ctx->context_initialized = TRUE; - vschannel_capture_alpn(tls_ctx); + // The final handshake flight often arrives in the same TCP segment as the + // server's first application record (for HTTP/2, the SETTINGS frame), which + // the handshake hands back as SECBUFFER_EXTRA. Those bytes are already off + // the socket, so they must be carried into the read buffer; otherwise the + // first vschannel_read() would skip them and H2Conn would desync. Grow the + // staging buffer if the bundled data exceeds one record. + if(ExtraData.pvBuffer != NULL) { + if(ExtraData.cbBuffer > 0) { + if(ExtraData.cbBuffer > tls_ctx->recv_buf_cap) { + unsigned char *grown = (unsigned char *)LocalAlloc(LPTR, ExtraData.cbBuffer); + if(grown != NULL) { + LocalFree(tls_ctx->recv_buf); + tls_ctx->recv_buf = grown; + tls_ctx->recv_buf_cap = ExtraData.cbBuffer; + } + } + if(ExtraData.cbBuffer <= tls_ctx->recv_buf_cap) { + MoveMemory(tls_ctx->recv_buf, ExtraData.pvBuffer, ExtraData.cbBuffer); + tls_ctx->recv_buf_len = ExtraData.cbBuffer; + } + } + LocalFree(ExtraData.pvBuffer); + } - disconnect_from_server(tls_ctx); - tls_ctx->context_initialized = FALSE; - tls_ctx->socket = INVALID_SOCKET; + return 0; +} - return vschannel_get_alpn(tls_ctx, out, out_cap); +// vschannel_write encrypts and sends `len` application bytes over the open +// connection, chunked to the negotiated maximum record size. Returns the number +// of bytes consumed (== len) on success, or -1 on error. +INT vschannel_write(TlsContext *tls_ctx, const char *buf, INT len) { + SecBufferDesc Message; + SecBuffer Buffers[4]; + SECURITY_STATUS scRet; + PBYTE io; + DWORD off; + INT cbData; + + if(len <= 0) { + return 0; + } + if(vschannel_ensure_stream_state(tls_ctx) != SEC_E_OK) { + return -1; + } + + // Reuse the per-context send buffer (one full record) rather than allocating + // on every write; the HTTP/2 driver issues many small writes per request. + io = (PBYTE)tls_ctx->send_buf; + + off = 0; + while(off < (DWORD)len) { + DWORD chunk = (DWORD)len - off; + if(chunk > tls_ctx->stream_sizes.cbMaximumMessage) { + chunk = tls_ctx->stream_sizes.cbMaximumMessage; + } + memcpy(io + tls_ctx->stream_sizes.cbHeader, buf + off, chunk); + + Buffers[0].pvBuffer = io; + Buffers[0].cbBuffer = tls_ctx->stream_sizes.cbHeader; + Buffers[0].BufferType = SECBUFFER_STREAM_HEADER; + Buffers[1].pvBuffer = io + tls_ctx->stream_sizes.cbHeader; + Buffers[1].cbBuffer = chunk; + Buffers[1].BufferType = SECBUFFER_DATA; + Buffers[2].pvBuffer = io + tls_ctx->stream_sizes.cbHeader + chunk; + Buffers[2].cbBuffer = tls_ctx->stream_sizes.cbTrailer; + Buffers[2].BufferType = SECBUFFER_STREAM_TRAILER; + Buffers[3].BufferType = SECBUFFER_EMPTY; + + Message.ulVersion = SECBUFFER_VERSION; + Message.cBuffers = 4; + Message.pBuffers = Buffers; + + scRet = tls_ctx->sspi->EncryptMessage(&tls_ctx->h_context, 0, &Message, 0); + if(FAILED(scRet)) { + vschannel_set_last_error(tls_ctx, scRet); + return -1; + } + + DWORD to_send = Buffers[0].cbBuffer + Buffers[1].cbBuffer + Buffers[2].cbBuffer; + DWORD sent = 0; + while(sent < to_send) { + cbData = send(tls_ctx->socket, (char*)io + sent, (int)(to_send - sent), 0); + if(cbData == SOCKET_ERROR || cbData == 0) { + vschannel_set_last_error(tls_ctx, WSAGetLastError()); + return -1; + } + sent += (DWORD)cbData; + } + off += chunk; + } + + return len; +} + +// vschannel_read returns up to `cap` decrypted application bytes from the open +// connection. It returns the number of bytes written into `buf` (> 0), 0 at +// end of stream (close_notify / context expired / peer closed the socket), or +// -1 on error. Leftover decrypted plaintext that did not fit in `buf`, and +// ciphertext that did not yet form a complete record, are carried on the +// TlsContext across calls. +INT vschannel_read(TlsContext *tls_ctx, char *buf, INT cap) { + SecBufferDesc Message; + SecBuffer Buffers[4]; + SecBuffer ExtraBuffer; + SecBuffer *pDataBuffer; + SecBuffer *pExtraBuffer; + SECURITY_STATUS scRet; + INT cbData; + int i; + + if(cap <= 0) { + return 0; + } + if(vschannel_ensure_stream_state(tls_ctx) != SEC_E_OK) { + return -1; + } + + // 1. Serve leftover decrypted plaintext from a previous record first. + if(tls_ctx->plain_buf_off < tls_ctx->plain_buf_len) { + DWORD avail = tls_ctx->plain_buf_len - tls_ctx->plain_buf_off; + DWORD n = avail < (DWORD)cap ? avail : (DWORD)cap; + memcpy(buf, tls_ctx->plain_buf + tls_ctx->plain_buf_off, n); + tls_ctx->plain_buf_off += n; + return (INT)n; + } + + if(tls_ctx->stream_eof) { + return 0; + } + + for(;;) { + // Try to decrypt whatever ciphertext we have buffered. + if(tls_ctx->recv_buf_len > 0) { + Buffers[0].pvBuffer = tls_ctx->recv_buf; + Buffers[0].cbBuffer = tls_ctx->recv_buf_len; + Buffers[0].BufferType = SECBUFFER_DATA; + Buffers[1].BufferType = SECBUFFER_EMPTY; + Buffers[2].BufferType = SECBUFFER_EMPTY; + Buffers[3].BufferType = SECBUFFER_EMPTY; + + Message.ulVersion = SECBUFFER_VERSION; + Message.cBuffers = 4; + Message.pBuffers = Buffers; + + scRet = tls_ctx->sspi->DecryptMessage(&tls_ctx->h_context, &Message, 0, NULL); + + if(scRet == SEC_E_OK || scRet == SEC_I_RENEGOTIATE || scRet == SEC_I_CONTEXT_EXPIRED) { + pDataBuffer = NULL; + pExtraBuffer = NULL; + for(i = 1; i < 4; i++) { + if(pDataBuffer == NULL && Buffers[i].BufferType == SECBUFFER_DATA) { + pDataBuffer = &Buffers[i]; + } + if(pExtraBuffer == NULL && Buffers[i].BufferType == SECBUFFER_EXTRA) { + pExtraBuffer = &Buffers[i]; + } + } + + // Copy out decrypted application data: as much as fits in the + // caller's buffer, the rest into the plaintext carryover. Both + // reads happen before the SECBUFFER_EXTRA move below, since the + // data buffer points inside recv_buf. + INT produced = 0; + if(pDataBuffer && pDataBuffer->cbBuffer > 0) { + DWORD data_len = pDataBuffer->cbBuffer; + DWORD n = data_len < (DWORD)cap ? data_len : (DWORD)cap; + memcpy(buf, pDataBuffer->pvBuffer, n); + produced = (INT)n; + DWORD rest = data_len - n; + if(rest > 0) { + memcpy(tls_ctx->plain_buf, (unsigned char*)pDataBuffer->pvBuffer + n, rest); + tls_ctx->plain_buf_len = rest; + tls_ctx->plain_buf_off = 0; + } + } + + // Carry trailing ciphertext (start of the next record) to the + // front of recv_buf for the next decrypt. + if(pExtraBuffer) { + MoveMemory(tls_ctx->recv_buf, pExtraBuffer->pvBuffer, pExtraBuffer->cbBuffer); + tls_ctx->recv_buf_len = pExtraBuffer->cbBuffer; + } else { + tls_ctx->recv_buf_len = 0; + } + + if(scRet == SEC_I_RENEGOTIATE) { + // The server requested a new handshake. Run it, then carry any + // extra data it left behind and keep reading. + SECURITY_STATUS rh = client_handshake_loop(tls_ctx, FALSE, &ExtraBuffer); + if(rh != SEC_E_OK) { + vschannel_set_last_error(tls_ctx, rh); + return -1; + } + if(ExtraBuffer.pvBuffer) { + if(ExtraBuffer.cbBuffer <= tls_ctx->recv_buf_cap) { + MoveMemory(tls_ctx->recv_buf, ExtraBuffer.pvBuffer, ExtraBuffer.cbBuffer); + tls_ctx->recv_buf_len = ExtraBuffer.cbBuffer; + } + LocalFree(ExtraBuffer.pvBuffer); + } + } else if(scRet == SEC_I_CONTEXT_EXPIRED) { + // Graceful close_notify from the server. + tls_ctx->stream_eof = TRUE; + if(produced > 0) { + return produced; + } + return 0; + } + + if(produced > 0) { + return produced; + } + // A record with no application data (e.g. a session ticket or a + // renegotiation). Keep going. + continue; + } else if(scRet == SEC_E_INCOMPLETE_MESSAGE) { + // Need more bytes to complete the current record: fall through to + // recv() more ciphertext. + } else { + vschannel_set_last_error(tls_ctx, scRet); + return -1; + } + } + + // Receive more ciphertext. + if(tls_ctx->recv_buf_len >= tls_ctx->recv_buf_cap) { + // Should not happen: a single record fits in recv_buf_cap. + vschannel_set_last_error(tls_ctx, SEC_E_INTERNAL_ERROR); + return -1; + } + cbData = recv(tls_ctx->socket, (char*)tls_ctx->recv_buf + tls_ctx->recv_buf_len, + (int)(tls_ctx->recv_buf_cap - tls_ctx->recv_buf_len), 0); + if(cbData == SOCKET_ERROR) { + vschannel_set_last_error(tls_ctx, WSAGetLastError()); + return -1; + } + if(cbData == 0) { + // Peer closed the socket. Any buffered bytes are an incomplete record. + tls_ctx->stream_eof = TRUE; + return 0; + } + tls_ctx->recv_buf_len += (DWORD)cbData; + } +} + +// vschannel_h2_close sends a close_notify alert and tears down the connection. +void vschannel_h2_close(TlsContext *tls_ctx) { + if(tls_ctx->context_initialized) { + disconnect_from_server(tls_ctx); + tls_ctx->context_initialized = FALSE; + tls_ctx->socket = INVALID_SOCKET; + } + vschannel_cleanup(tls_ctx); } diff --git a/thirdparty/vschannel/vschannel.h b/thirdparty/vschannel/vschannel.h index e17a6125a..6dd9729e0 100644 --- a/thirdparty/vschannel/vschannel.h +++ b/thirdparty/vschannel/vschannel.h @@ -28,10 +28,24 @@ TlsContext new_tls_context(); // ALPN (RFC 7301) support. `wire` is the standard ALPN wire format: each // protocol name preceded by a 1-byte length, e.g. "\x02h2\x08http/1.1". +// vschannel_alpn_supported reports whether this Windows version's SChannel +// can advertise ALPN at all (Windows 8.1+). +INT vschannel_alpn_supported(); void vschannel_set_alpn(TlsContext *tls_ctx, const char *wire, INT len); INT vschannel_get_alpn(TlsContext *tls_ctx, char *out, INT out_cap); INT vschannel_alpn_probe(TlsContext *tls_ctx, INT iport, LPWSTR host, char *out, INT out_cap); +// vschannel_request_on_open runs a one-shot HTTP/1.1 request over an already +// open connection (the HTTP/1.1 fallback when `h2` was not negotiated). +INT vschannel_request_on_open(TlsContext *tls_ctx, CHAR *req, DWORD req_len, CHAR **out, vschannel_allocator afn); + +// Streaming transport for the HTTP/2 driver: open the connection and keep it +// open, then move raw application bytes across it. See vschannel.c. +INT vschannel_h2_connect(TlsContext *tls_ctx, INT iport, LPWSTR host); +INT vschannel_write(TlsContext *tls_ctx, const char *buf, INT len); +INT vschannel_read(TlsContext *tls_ctx, char *buf, INT cap); +void vschannel_h2_close(TlsContext *tls_ctx); + static void vschannel_init(TlsContext *tls_ctx, BOOL validate_server_certificate); static void vschannel_cleanup(TlsContext *tls_ctx); diff --git a/vlib/net/http/backend.c.v b/vlib/net/http/backend.c.v index e07540b86..4332bdefa 100644 --- a/vlib/net/http/backend.c.v +++ b/vlib/net/http/backend.c.v @@ -71,6 +71,17 @@ fn (req &Request) h2_do(mut ssl_conn ssl.SSLConn, method Method, host_name strin defer { ssl_conn.shutdown() or {} } + mut conn := new_h2_conn(ssl_conn) + return req.h2_exchange(mut conn, method, host_name, port, path, data, header)! +} + +// h2_exchange runs a single request over an already-established H2Conn and +// converts the result to a net.http Response. It is transport-agnostic: the +// caller is responsible for building the H2Conn over whatever ALPN-negotiated +// `h2` transport (net.ssl on most platforms, SChannel on default Windows) and +// for tearing it down afterwards. The request's streaming callbacks and stop +// limits are adapted onto the H2 chunk hook, as documented on h2_do. +fn (req &Request) h2_exchange(mut conn H2Conn, method Method, host_name string, port int, path string, data string, header Header) !Response { base := req.to_h2_request(method, h2_authority(host_name, port), path, data, header) on_progress := req.on_progress on_progress_body := req.on_progress_body @@ -96,7 +107,6 @@ fn (req &Request) h2_do(mut ssl_conn ssl.SSLConn, method Method, host_name strin stop_copying_limit: req.stop_copying_limit stop_receiving_limit: req.stop_receiving_limit } - mut conn := new_h2_conn(ssl_conn) h2resp := conn.do(h2req)! if req.on_finish != unsafe { nil } { req.on_finish(req, u64(h2resp.body.len))! diff --git a/vlib/net/http/backend_vschannel_windows.c.v b/vlib/net/http/backend_vschannel_windows.c.v index cf4c35f38..2ca58ffd0 100644 --- a/vlib/net/http/backend_vschannel_windows.c.v +++ b/vlib/net/http/backend_vschannel_windows.c.v @@ -17,8 +17,29 @@ fn C.new_tls_context() C.TlsContext fn C.vschannel_use_tls12_client_protocol() fn C.vschannel_init(tls_ctx &C.TlsContext, validate_server_certificate C.BOOL) fn C.vschannel_last_error(tls_ctx &C.TlsContext) int +fn C.vschannel_alpn_supported() int + +// vschannel_request_on_open mirrors C.request (declared in builtin/cfns.c.v) but +// runs over an already-open connection. See thirdparty/vschannel/vschannel.c. +fn C.vschannel_request_on_open(&C.TlsContext, &u8, u32, &&u8, fn (voidptr, isize) voidptr) i32 fn vschannel_ssl_do(req &Request, port int, method Method, host_name string, path string, data string, header Header) !Response { + // When HTTP/2 is enabled (the default for https), advertise ALPN `h2` and, + // if the server selects it, speak HTTP/2. Otherwise fall back to HTTP/1.1 + // over the same connection (see vschannel_h2_do). When HTTP/2 is opted out + // of — or this Windows version's SChannel predates client-side ALPN + // (pre-8.1), where injecting the ALPN buffer can fail the handshake + // outright — use the original one-shot HTTP/1.1 path with no ALPN. + if req.enable_http2 && C.vschannel_alpn_supported() != 0 { + return vschannel_h2_do(req, port, method, host_name, path, data, header)! + } + return vschannel_h1_do(req, port, method, host_name, path, data, header)! +} + +// vschannel_h1_do is the original one-shot HTTP/1.1 SChannel request path: +// connect, handshake, send the whole request, read the whole response, +// disconnect. It is used when HTTP/2 is disabled. +fn vschannel_h1_do(req &Request, port int, method Method, host_name string, path string, data string, header Header) !Response { mut ctx := C.new_tls_context() C.vschannel_use_tls12_client_protocol() C.vschannel_init(&ctx, C.BOOL(if req.validate { 1 } else { 0 })) @@ -31,6 +52,27 @@ fn vschannel_ssl_do(req &Request, port int, method Method, host_name string, pat length := C.request(&ctx, port, addr.to_wide(), sdata.str, sdata.len, &buff, v_realloc) err_code := C.vschannel_last_error(&ctx) C.vschannel_cleanup(&ctx) + return req.vschannel_finish_response(buff, length, err_code)! +} + +// vschannel_h1_on_open runs the one-shot HTTP/1.1 request over a connection that +// vschannel_h2_connect() already opened, used as the fallback when the server +// did not negotiate `h2`. It consumes (and cleans up) `ctx`. +fn (req &Request) vschannel_h1_on_open(ctx &C.TlsContext, method Method, host_name string, port int, path string, data string, header Header) !Response { + mut buff := unsafe { malloc_noscan(C.vsc_init_resp_buff_size) } + sdata := req.build_request_headers_with(method, host_name, port, path, data, header) + $if trace_http_request ? { + eprintln('> ${sdata}') + } + length := C.vschannel_request_on_open(ctx, sdata.str, sdata.len, &buff, v_realloc) + err_code := C.vschannel_last_error(ctx) + C.vschannel_cleanup(ctx) + return req.vschannel_finish_response(buff, length, err_code)! +} + +// vschannel_finish_response turns the raw response buffer produced by the C +// request paths into a parsed Response, firing the progress/finish callbacks. +fn (req &Request) vschannel_finish_response(buff &u8, length int, err_code int) !Response { if length <= 0 { if err_code != 0 { return vschannel_request_error(err_code) diff --git a/vlib/net/http/h2_client_test.v b/vlib/net/http/h2_client_test.v index 9933f98a0..da61302a2 100644 --- a/vlib/net/http/h2_client_test.v +++ b/vlib/net/http/h2_client_test.v @@ -85,14 +85,8 @@ fn test_http2_fetch_real_server() { $if !network ? { return } - $if windows && !no_vschannel ? { - // On Windows the default HTTPS client is SChannel, which has no ALPN - // yet (vlang/v#27383), so it cannot negotiate HTTP/2. Covered with - // `-d no_vschannel` (mbedtls client). - eprintln('skipping: SChannel client has no ALPN/HTTP2 support yet') - return - } - // HTTP/2 is negotiated by default for https requests. + // HTTP/2 is negotiated by default for https requests. On Windows this runs + // over the SChannel backend's ALPN + HTTP/2 path (vlang/v#27383). resp := get('https://www.google.com/')! assert resp.version() == .v2_0 assert resp.status_code == 200 diff --git a/vlib/net/http/server_tls_test.v b/vlib/net/http/server_tls_test.v index 743db45b0..f936c399d 100644 --- a/vlib/net/http/server_tls_test.v +++ b/vlib/net/http/server_tls_test.v @@ -81,13 +81,6 @@ fn test_server_tls_h2_negotiation() { eprintln('skipping: TLS server not implemented for -d use_openssl yet') return } - $if windows && !no_vschannel ? { - // On Windows the default HTTP client uses SChannel, which does not yet - // advertise ALPN, so it cannot negotiate HTTP/2. Skip here; the path is - // covered with `-d no_vschannel` (which uses the mbedtls client). - eprintln('skipping: SChannel client has no ALPN/HTTP2 support yet') - return - } port := pick_port() or { assert false, 'pick_port: ${err}' return diff --git a/vlib/net/http/vschannel_h2_windows.c.v b/vlib/net/http/vschannel_h2_windows.c.v new file mode 100644 index 000000000..72637b35b --- /dev/null +++ b/vlib/net/http/vschannel_h2_windows.c.v @@ -0,0 +1,88 @@ +// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. +module http + +// HTTP/2 over the Windows SChannel backend. This wires the streaming transport +// primitives in thirdparty/vschannel/vschannel.c into the backend-agnostic +// HTTP/2 client (h2_conn.v / h2_client.v), mirroring what net_ssl_do does with +// net.ssl on the other platforms. See vlang/v#27383. + +fn C.vschannel_h2_connect(tls_ctx &C.TlsContext, iport int, host &u16) int +fn C.vschannel_write(tls_ctx &C.TlsContext, buf &char, len int) int +fn C.vschannel_read(tls_ctx &C.TlsContext, buf &char, cap int) int +fn C.vschannel_h2_close(tls_ctx &C.TlsContext) + +// VSchannelH2Transport adapts the SChannel streaming C API (vschannel_read / +// vschannel_write over an open TLS connection) to the H2Transport interface +// that H2Conn drives. It borrows a &C.TlsContext owned by the caller, which +// must outlive the transport. +struct VSchannelH2Transport { +mut: + ctx &C.TlsContext = unsafe { nil } +} + +// read fills `buf` with up to buf.len decrypted application bytes, returning the +// number read (0 at end of stream). H2Conn treats a 0/closed read as the +// connection closing, matching net.ssl semantics. +fn (mut t VSchannelH2Transport) read(mut buf []u8) !int { + if buf.len == 0 { + return 0 + } + n := C.vschannel_read(t.ctx, &char(buf.data), buf.len) + if n < 0 { + return error('http: vschannel_read failed') + } + return n +} + +// write encrypts and sends all of `buf`, returning the number of bytes consumed. +fn (mut t VSchannelH2Transport) write(buf []u8) !int { + if buf.len == 0 { + return 0 + } + n := C.vschannel_write(t.ctx, &char(buf.data), buf.len) + if n < 0 { + return error('http: vschannel_write failed') + } + return n +} + +// vschannel_h2_do opens an SChannel TLS connection advertising ALPN +// `h2`/`http/1.1`. If the server selects `h2`, it runs the request over HTTP/2. +// Otherwise it speaks HTTP/1.1 over the *same* open connection (so a server +// that does not do HTTP/2 — or ALPN at all — costs no extra handshake, and +// single-connection servers are not broken by a probe-then-reconnect). Any +// error once HTTP/2 is in use propagates as-is. +fn vschannel_h2_do(req &Request, port int, method Method, host_name string, path string, data string, header Header) !Response { + mut ctx := C.new_tls_context() + C.vschannel_use_tls12_client_protocol() + C.vschannel_init(&ctx, C.BOOL(if req.validate { 1 } else { 0 })) + wire := alpn_wire(['h2', 'http/1.1']) + C.vschannel_set_alpn(&ctx, &char(wire.data), wire.len) + + if C.vschannel_h2_connect(&ctx, port, host_name.to_wide()) != 0 { + err_code := C.vschannel_last_error(&ctx) + C.vschannel_cleanup(&ctx) + if err_code != 0 { + return vschannel_request_error(err_code) + } + return error('http: vschannel connect failed') + } + + mut abuf := []u8{len: 16} + an := C.vschannel_get_alpn(&ctx, &char(abuf.data), abuf.len) + if an > 0 && abuf[..an].bytestr() == 'h2' { + defer { + C.vschannel_h2_close(&ctx) + } + mut transport := &VSchannelH2Transport{ + ctx: &ctx + } + mut conn := new_h2_conn(transport) + return req.h2_exchange(mut conn, method, host_name, port, path, data, header)! + } + + // Server chose HTTP/1.1 (or no ALPN): reuse the open connection for h1. + return req.vschannel_h1_on_open(&ctx, method, host_name, port, path, data, header)! +} -- 2.39.5