From 35ffbabf6f192b963340da285b7d2216c318598f Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Mon, 8 Jun 2026 07:33:01 -0400 Subject: [PATCH] net.http: add server-side HTTP/2 (ALPN + h2 frame demux) (#27382) * net.http: add server-side HTTP/2 (ALPN + h2 frame demux) Build on TLS termination (#27373) to let net.http.Server speak HTTP/2. - New `enable_http2` field on Server. When set on a TLS listener, the listener advertises ALPN `h2, http/1.1`. After the handshake, the TLS worker checks `negotiated_alpn()`: if `h2`, it dispatches to the new HTTP/2 driver; otherwise the existing HTTP/1.1 path is unchanged. - New h2_server.v (H2ServerConn): reads the client preface, exchanges SETTINGS (advertising SETTINGS_MAX_CONCURRENT_STREAMS=1 so requests serialize), and runs the frame loop. HEADERS+CONTINUATION are assembled and HPACK-decoded into a net.http.Request; DATA frames populate the body and replenish flow control; SETTINGS / PING / WINDOW_UPDATE / GOAWAY / RST_STREAM / PRIORITY are serviced inline. When the request stream closes, the existing Handler.handle(Request) Response interface is invoked unchanged; the Response is HPACK-encoded into HEADERS + DATA(END_STREAM) and sent back. - Hop-by-hop response headers are dropped (RFC 7540 Section 8.1.2.2). The request body is capped at 8 MiB with RST_STREAM(REFUSED_STREAM) on overflow. - The Handler contract is untouched: req.url is the request-target (the :path pseudo-header) and Host comes from :authority, so existing HTTP/1.1 handlers run with no changes on the new transport. Tests: h2_server_test.v drives the server through an in-memory blocking pipe with the existing HTTP/2 client (GET, POST with a body, non-200 status, all round-trip). server_tls_test.v adds a TLS + ALPN end-to-end test asserting http.fetch(enable_http2: true) negotiates h2 against the same listener that still serves HTTP/1.1 to non-h2 clients. Full vlib/net/http suite is green on both backends; passes under -W -cstrict -cc clang. This is opt-in and additive: with enable_http2 unset (or for non-TLS servers), behaviour is exactly as before. Stream multiplexing with a background reader is a planned follow-up (this driver serializes requests). Co-Authored-By: Claude Opus 4.7 * net.http: server-side h2 send flow control + gate Windows ALPN test Address two findings from review of the server-side HTTP/2 PR: - Server response DATA now respects the HTTP/2 send flow-control windows (RFC 7540 Section 6.9). send_body bounds each DATA frame by min(connection, stream) window, decrements both after sending, and waits for WINDOW_UPDATE (servicing SETTINGS / PING / WINDOW_UPDATE, and a RST_STREAM for the stream being written) when a window is exhausted. apply_settings now also adjusts every active stream's send window by the delta when the peer changes SETTINGS_INITIAL_WINDOW_SIZE (Section 6.9.2). Previously a client that lowered its initial window could be sent more DATA than permitted and reset the stream with FLOW_CONTROL_ERROR. - Gate test_server_tls_h2_negotiation so it skips on the default Windows configuration: the SChannel client does not advertise ALPN, so it cannot negotiate HTTP/2 and the version assertion would fail. The path stays covered with `-d no_vschannel` (mbedtls client), matching how the rest of the suite treats the SChannel limitation. Adds test_h2_server_respects_send_window: a raw client advertises SETTINGS_INITIAL_WINDOW_SIZE=10, and the test asserts the server's first DATA frame is <= 10 bytes and that the full 100-byte body is delivered after a WINDOW_UPDATE. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Richard Wheeler Co-authored-by: Claude Opus 4.7 --- vlib/net/http/h2_server.v | 530 ++++++++++++++++++++ vlib/net/http/h2_server_test.v | 305 +++++++++++ vlib/net/http/server.v | 1 + vlib/net/http/server_tls_notd_use_openssl.v | 16 + vlib/net/http/server_tls_test.v | 63 +++ 5 files changed, 915 insertions(+) create mode 100644 vlib/net/http/h2_server.v create mode 100644 vlib/net/http/h2_server_test.v diff --git a/vlib/net/http/h2_server.v b/vlib/net/http/h2_server.v new file mode 100644 index 000000000..9a32cf3ac --- /dev/null +++ b/vlib/net/http/h2_server.v @@ -0,0 +1,530 @@ +// 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 + +// This file implements a minimal server-side HTTP/2 connection driver on top +// of the existing framing (h2_frame.v) and HPACK (h2_hpack.v) layers. It is +// intentionally serial: SETTINGS_MAX_CONCURRENT_STREAMS is advertised as 1, +// so the peer sends one request at a time. Concurrent stream handling and +// background writers are a planned follow-up. + +// h2_server_max_concurrent_streams is the value advertised in our SETTINGS; +// the peer may not open more streams than this at once. +const h2_server_max_concurrent_streams = u32(1) + +// h2_server_default_window is the default initial flow-control window +// (RFC 7540 Section 6.9.2). +const h2_server_default_window = u32(65535) + +// h2_server_max_request_body caps the in-memory request body the server will +// accept before responding with a stream error. Bodies larger than this are +// rejected with RST_STREAM(REFUSED_STREAM). +const h2_server_max_request_body = 8 * 1024 * 1024 + +// H2ServerStream holds the state of one in-flight server-side stream. +struct H2ServerStream { +mut: + id u32 + hpack_block []u8 // assembled HEADERS + CONTINUATION fragments + headers []H2HeaderField + body []u8 + end_headers bool + end_stream bool + headers_done bool // set once we've decoded the HPACK block + send_window i64 +} + +// H2ServerConn drives one server-side HTTP/2 connection over a transport. +struct H2ServerConn { +mut: + transport H2Transport + encoder H2HpackEncoder + decoder H2HpackDecoder + peer H2PeerSettings + rbuf []u8 + send_window i64 = i64(h2_server_default_window) + streams map[u32]&H2ServerStream + last_stream_id u32 + awaiting_cont u32 // non-zero when mid-CONTINUATION on this stream + closing bool +} + +// serve_h2_conn drives a single HTTP/2 server-side connection until the +// transport closes or a protocol error forces a GOAWAY. `handler` is invoked +// once per fully-received request stream. +fn serve_h2_conn(mut transport H2Transport, mut handler Handler) ! { + mut c := &H2ServerConn{ + transport: transport + } + c.serve(mut handler) or { + // Best-effort GOAWAY before bailing. + c.send_goaway(.protocol_error, err.msg()) or {} + return err + } +} + +fn (mut c H2ServerConn) serve(mut handler Handler) ! { + c.read_client_preface()! + c.send_initial_settings()! + for !c.closing { + frame := c.read_frame() or { + // Treat a clean transport close as end of session. + return + } + c.dispatch_frame(frame, mut handler)! + } +} + +fn (mut c H2ServerConn) read_client_preface() ! { + c.fill_at_least(h2_client_preface.len)! + got := c.rbuf[..h2_client_preface.len].bytestr() + if got != h2_client_preface { + return error('h2 server: invalid connection preface') + } + c.rbuf = c.rbuf[h2_client_preface.len..].clone() +} + +fn (mut c H2ServerConn) send_initial_settings() ! { + c.send_frame(H2SettingsFrame{ + settings: [ + H2Setting{h2_settings_enable_push, 0}, + H2Setting{h2_settings_max_concurrent_streams, h2_server_max_concurrent_streams}, + H2Setting{h2_settings_initial_window_size, h2_server_default_window}, + H2Setting{h2_settings_max_frame_size, h2_default_max_frame_size}, + ] + })! +} + +fn (mut c H2ServerConn) dispatch_frame(frame H2Frame, mut handler Handler) ! { + // RFC 7540 §6.10: once a HEADERS or PUSH_PROMISE without END_HEADERS is + // seen, the next frame must be a CONTINUATION on the same stream. + if c.awaiting_cont != 0 { + if frame is H2ContinuationFrame { + if frame.stream_id != c.awaiting_cont { + return error('h2 server: CONTINUATION on the wrong stream') + } + } else { + return error('h2 server: expected CONTINUATION after HEADERS without END_HEADERS') + } + } + match frame { + H2SettingsFrame { + if !frame.ack { + c.apply_settings(frame.settings) + c.send_frame(H2SettingsFrame{ + ack: true + })! + } + } + H2PingFrame { + if !frame.ack { + c.send_frame(H2PingFrame{ + ack: true + data: frame.data + })! + } + } + H2WindowUpdateFrame { + if frame.stream_id == 0 { + c.send_window += i64(frame.window_size_increment) + } else if mut s := c.streams[frame.stream_id] { + s.send_window += i64(frame.window_size_increment) + } + } + H2GoawayFrame { + c.closing = true + } + H2RstStreamFrame { + c.streams.delete(frame.stream_id) + } + H2PriorityFrame { + // Priority is advisory; ignore. + } + H2HeadersFrame { + c.on_headers(frame, mut handler)! + } + H2ContinuationFrame { + c.on_continuation(frame, mut handler)! + } + H2DataFrame { + c.on_data(frame, mut handler)! + } + H2PushPromiseFrame { + // Clients should not push. Treat as a protocol error. + return error('h2 server: PUSH_PROMISE from client') + } + H2UnknownFrame { + // RFC 7540 §4.1: ignore unknown frame types. + } + } +} + +fn (mut c H2ServerConn) apply_settings(settings []H2Setting) { + for s in settings { + match s.id { + h2_settings_header_table_size { + c.peer.header_table_size = s.value + } + h2_settings_enable_push { + c.peer.enable_push = s.value != 0 + } + h2_settings_max_concurrent_streams { + c.peer.max_concurrent_streams = s.value + } + h2_settings_initial_window_size { + // RFC 7540 Section 6.9.2: a change to the initial window size + // adjusts the send window of every active stream by the delta. + delta := i64(s.value) - i64(c.peer.initial_window_size) + c.peer.initial_window_size = s.value + for _, mut st in c.streams { + st.send_window += delta + } + } + h2_settings_max_frame_size { + c.peer.max_frame_size = s.value + } + h2_settings_max_header_list_size { + c.peer.max_header_list_size = s.value + } + else {} + } + } +} + +fn (mut c H2ServerConn) on_headers(frame H2HeadersFrame, mut handler Handler) ! { + // Stream ids from the client must be odd and strictly increasing. + if frame.stream_id & 1 == 0 || frame.stream_id <= c.last_stream_id { + return error('h2 server: invalid client stream id ${frame.stream_id}') + } + c.last_stream_id = frame.stream_id + mut s := &H2ServerStream{ + id: frame.stream_id + hpack_block: frame.fragment.clone() + end_headers: frame.end_headers + end_stream: frame.end_stream + send_window: i64(c.peer.initial_window_size) + } + c.streams[frame.stream_id] = s + if !frame.end_headers { + c.awaiting_cont = frame.stream_id + return + } + c.finalize_headers(mut s, mut handler)! +} + +fn (mut c H2ServerConn) on_continuation(frame H2ContinuationFrame, mut handler Handler) ! { + mut s := c.streams[frame.stream_id] or { + return error('h2 server: CONTINUATION for unknown stream ${frame.stream_id}') + } + s.hpack_block << frame.fragment + if frame.end_headers { + s.end_headers = true + c.awaiting_cont = 0 + c.finalize_headers(mut s, mut handler)! + } +} + +fn (mut c H2ServerConn) finalize_headers(mut s H2ServerStream, mut handler Handler) ! { + s.headers = c.decoder.decode(s.hpack_block) or { + c.send_rst_stream(s.id, .compression_error)! + c.streams.delete(s.id) + return + } + s.headers_done = true + if s.end_stream { + c.run_request(mut s, mut handler)! + } +} + +fn (mut c H2ServerConn) on_data(frame H2DataFrame, mut handler Handler) ! { + mut s := c.streams[frame.stream_id] or { + // DATA for an unknown stream (likely already RST'd); just drop and + // keep flow control consistent. + if frame.data.len > 0 { + c.send_window_update(0, u32(frame.data.len))! + } + return + } + if !s.headers_done { + return error('h2 server: DATA before END_HEADERS') + } + if frame.data.len > 0 { + if s.body.len + frame.data.len > h2_server_max_request_body { + c.send_rst_stream(s.id, .refused_stream)! + c.streams.delete(s.id) + return + } + s.body << frame.data + // Replenish the connection window; per-stream we replenish on + // completion since we hold the body in memory. + c.send_window_update(0, u32(frame.data.len))! + c.send_window_update(s.id, u32(frame.data.len))! + } + if frame.end_stream { + s.end_stream = true + c.run_request(mut s, mut handler)! + } +} + +fn (mut c H2ServerConn) run_request(mut s H2ServerStream, mut handler Handler) ! { + req := c.build_request(s) or { + c.send_rst_stream(s.id, .protocol_error)! + c.streams.delete(s.id) + return + } + resp := handler.handle(req) + c.send_response(s.id, resp)! + c.streams.delete(s.id) +} + +fn (mut c H2ServerConn) build_request(s &H2ServerStream) !Request { + mut req := Request{ + version: .v2_0 + header: new_header() + } + mut method := '' + mut path := '' + mut authority := '' + mut scheme := 'https' + for f in s.headers { + match f.name { + ':method' { + method = f.value + } + ':path' { + path = f.value + } + ':authority' { + authority = f.value + } + ':scheme' { + scheme = f.value + } + else { + if f.name.starts_with(':') { + return error('h2 server: unknown pseudo-header ${f.name}') + } + req.header.add_custom(f.name, f.value) or {} + } + } + } + if method == '' || path == '' { + return error('h2 server: missing :method or :path') + } + req.method = method_from_str(method) + if authority != '' && !req.header.contains(.host) { + req.header.add(.host, authority) + } + // Match the HTTP/1.1 path: req.url is the request-target (the :path + // pseudo-header), so handlers see the same shape on both transports. + _ = scheme // :scheme is parsed and discarded; handlers infer it from Host + req.url = path + req.data = s.body.bytestr() + req.host = authority + return req +} + +fn (mut c H2ServerConn) send_response(stream_id u32, resp Response) ! { + status := if resp.status_code == 0 { 200 } else { resp.status_code } + mut fields := [H2HeaderField{':status', status.str()}] + for key in resp.header.keys() { + lkey := key.to_lower() + // Drop hop-by-hop headers; HTTP/2 forbids them (RFC 7540 §8.1.2.2). + if lkey in ['connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'proxy-connection'] { + continue + } + for val in resp.header.custom_values(key) { + fields << H2HeaderField{lkey, val} + } + } + body := resp.body.bytes() + has_body := body.len > 0 + block := c.encoder.encode(fields) + c.send_header_block(stream_id, block, !has_body)! + if has_body { + c.send_body(stream_id, body)! + } +} + +fn (mut c H2ServerConn) send_header_block(stream_id u32, block []u8, end_stream bool) ! { + max := int(c.peer.max_frame_size) + if block.len <= max { + c.send_frame(H2HeadersFrame{ + stream_id: stream_id + fragment: block + end_headers: true + end_stream: end_stream + })! + return + } + c.send_frame(H2HeadersFrame{ + stream_id: stream_id + fragment: block[..max] + end_headers: false + end_stream: end_stream + })! + mut off := max + for off < block.len { + mut next := off + max + if next > block.len { + next = block.len + } + c.send_frame(H2ContinuationFrame{ + stream_id: stream_id + fragment: block[off..next] + end_headers: next == block.len + })! + off = next + } +} + +fn (mut c H2ServerConn) send_body(stream_id u32, body []u8) ! { + max := int(c.peer.max_frame_size) + mut off := 0 + for off < body.len { + // Respect both the connection and per-stream send windows + // (RFC 7540 Section 6.9). When either is exhausted, read frames until + // the peer grows a window with WINDOW_UPDATE. + for c.send_window <= 0 || c.stream_send_window(stream_id) <= 0 { + c.pump_for_window(stream_id)! + } + avail := if c.send_window < c.stream_send_window(stream_id) { + c.send_window + } else { + c.stream_send_window(stream_id) + } + mut chunk := body.len - off + if chunk > max { + chunk = max + } + if i64(chunk) > avail { + chunk = int(avail) + } + next := off + chunk + c.send_frame(H2DataFrame{ + stream_id: stream_id + data: body[off..next] + end_stream: next == body.len + })! + c.send_window -= i64(chunk) + if mut s := c.streams[stream_id] { + s.send_window -= i64(chunk) + } + off = next + } +} + +// stream_send_window returns the current per-stream send window, or 0 if the +// stream is gone. +fn (c &H2ServerConn) stream_send_window(stream_id u32) i64 { + if s := c.streams[stream_id] { + return s.send_window + } + return 0 +} + +// pump_for_window reads one frame while a response is blocked on flow control, +// servicing connection-level frames (SETTINGS / PING / WINDOW_UPDATE) and a +// RST_STREAM for the stream being written. +fn (mut c H2ServerConn) pump_for_window(stream_id u32) ! { + frame := c.read_frame()! + match frame { + H2SettingsFrame { + if !frame.ack { + c.apply_settings(frame.settings) + c.send_frame(H2SettingsFrame{ + ack: true + })! + } + } + H2PingFrame { + if !frame.ack { + c.send_frame(H2PingFrame{ + ack: true + data: frame.data + })! + } + } + H2WindowUpdateFrame { + if frame.stream_id == 0 { + c.send_window += i64(frame.window_size_increment) + } else if mut s := c.streams[frame.stream_id] { + s.send_window += i64(frame.window_size_increment) + } + } + H2RstStreamFrame { + if frame.stream_id == stream_id { + return error('h2 server: stream reset by peer while writing response') + } + } + else { + // With SETTINGS_MAX_CONCURRENT_STREAMS=1 no other stream frames are + // expected mid-response; ignore anything else defensively. + } + } +} + +fn (mut c H2ServerConn) send_window_update(stream_id u32, inc u32) ! { + if inc == 0 { + return + } + c.send_frame(H2WindowUpdateFrame{ + stream_id: stream_id + window_size_increment: inc + })! +} + +fn (mut c H2ServerConn) send_rst_stream(stream_id u32, code H2ErrorCode) ! { + c.send_frame(H2RstStreamFrame{ + stream_id: stream_id + error_code: u32(code) + })! +} + +fn (mut c H2ServerConn) send_goaway(code H2ErrorCode, msg string) ! { + c.send_frame(H2GoawayFrame{ + last_stream_id: c.last_stream_id + error_code: u32(code) + debug_data: msg.bytes() + })! +} + +fn (mut c H2ServerConn) read_frame() !H2Frame { + c.fill_at_least(h2_frame_header_len)! + header := h2_parse_frame_header(c.rbuf)! + if header.length > h2_default_max_frame_size { + return error('h2 server: frame larger than SETTINGS_MAX_FRAME_SIZE (${header.length})') + } + total := h2_frame_header_len + int(header.length) + c.fill_at_least(total)! + frame := h2_parse_frame(header, c.rbuf[h2_frame_header_len..total])! + c.rbuf = c.rbuf[total..].clone() + return frame +} + +fn (mut c H2ServerConn) fill_at_least(n int) ! { + for c.rbuf.len < n { + mut tmp := []u8{len: h2_conn_read_chunk} + got := c.transport.read(mut tmp)! + if got <= 0 { + return error('h2 server: connection closed by peer') + } + c.rbuf << tmp[..got] + } +} + +fn (mut c H2ServerConn) send_frame(f H2Frame) ! { + c.write_all(f.encode())! +} + +fn (mut c H2ServerConn) write_all(data []u8) ! { + mut sent := 0 + for sent < data.len { + n := c.transport.write(data[sent..])! + if n <= 0 { + return error('h2 server: transport write returned ${n}') + } + sent += n + } +} diff --git a/vlib/net/http/h2_server_test.v b/vlib/net/http/h2_server_test.v new file mode 100644 index 000000000..b4442dce0 --- /dev/null +++ b/vlib/net/http/h2_server_test.v @@ -0,0 +1,305 @@ +// Hermetic round-trip test for the server-side HTTP/2 driver. Uses an +// in-memory blocking ReadWriter pair so client and server share the same +// address space and no socket is required. +module http + +import sync +import time + +// PipeBuf is a one-way in-memory FIFO between a writer and a reader. +// `read` blocks (poll-with-sleep) until data is available, mirroring socket +// semantics so the h2 client and server can drive each other to completion. +struct PipeBuf { +mut: + mu &sync.Mutex = sync.new_mutex() + data []u8 + closed bool +} + +fn (mut p PipeBuf) write(buf []u8) !int { + p.mu.lock() + defer { + p.mu.unlock() + } + if p.closed { + return error('pipe: write to closed pipe') + } + p.data << buf + return buf.len +} + +fn (mut p PipeBuf) read(mut buf []u8) !int { + for { + p.mu.lock() + if p.data.len > 0 { + mut n := p.data.len + if n > buf.len { + n = buf.len + } + for i in 0 .. n { + buf[i] = p.data[i] + } + p.data = p.data[n..].clone() + p.mu.unlock() + return n + } + if p.closed { + p.mu.unlock() + return error('eof') + } + p.mu.unlock() + time.sleep(time.millisecond) + } + return 0 +} + +// PipeEnd is one half of a bidirectional pipe: reads come from `incoming`, +// writes go to `outgoing`. +struct PipeEnd { +mut: + incoming &PipeBuf + outgoing &PipeBuf +} + +fn (mut p PipeEnd) read(mut buf []u8) !int { + return p.incoming.read(mut buf)! +} + +fn (mut p PipeEnd) write(buf []u8) !int { + return p.outgoing.write(buf)! +} + +fn new_pipe() (&PipeEnd, &PipeEnd) { + mut a := &PipeBuf{} + mut b := &PipeBuf{} + client := &PipeEnd{ + incoming: b + outgoing: a + } + server := &PipeEnd{ + incoming: a + outgoing: b + } + return client, server +} + +struct ServerEchoHandler { +mut: + last_method Method + last_url string + last_body string +} + +fn (mut h ServerEchoHandler) handle(req Request) Response { + h.last_method = req.method + h.last_url = req.url + h.last_body = req.data + mut resp_header := new_header() + resp_header.add_custom('content-type', 'text/plain') or {} + // Echo the URL and body back so callers can verify end-to-end delivery + // without depending on Handler-state mutation across goroutines. + return Response{ + status_code: 200 + header: resp_header + body: 'echo: ${req.url} body=${req.data}' + } +} + +fn test_h2_server_basic_request() { + mut client_end, mut server_end := new_pipe() + + // Spawn the server-side h2 driver against the server end of the pipe. + mut handler := ServerEchoHandler{} + mut handler_iface := Handler(handler) + spawn fn [mut server_end, mut handler_iface] () { + mut transport := H2Transport(server_end) + serve_h2_conn(mut transport, mut handler_iface) or {} + }() + + // Drive a client-side request through the same pipe. + mut conn := new_h2_conn(client_end) + resp := conn.do(H2ClientRequest{ + method: 'GET' + authority: 'example.com' + path: '/hello' + }) or { + assert false, 'client do() failed: ${err}' + return + } + assert resp.status == 200 + assert resp.body.bytestr() == 'echo: /hello body=' + assert resp.headers.any(it.name == 'content-type' && it.value == 'text/plain') +} + +fn test_h2_server_post_with_body() { + mut client_end, mut server_end := new_pipe() + mut handler := ServerEchoHandler{} + mut handler_iface := Handler(handler) + spawn fn [mut server_end, mut handler_iface] () { + mut transport := H2Transport(server_end) + serve_h2_conn(mut transport, mut handler_iface) or {} + }() + + mut conn := new_h2_conn(client_end) + resp := conn.do(H2ClientRequest{ + method: 'POST' + authority: 'svc.example' + path: '/upload' + body: 'hello world'.bytes() + }) or { + assert false, 'client do() failed: ${err}' + return + } + assert resp.status == 200 + assert resp.body.bytestr() == 'echo: /upload body=hello world' +} + +struct StatusHandler { + code int +} + +fn (mut h StatusHandler) handle(req Request) Response { + return Response{ + status_code: h.code + body: 'status ${h.code}' + } +} + +fn test_h2_server_non_200_status() { + mut client_end, mut server_end := new_pipe() + mut handler := StatusHandler{ + code: 404 + } + mut handler_iface := Handler(handler) + spawn fn [mut server_end, mut handler_iface] () { + mut transport := H2Transport(server_end) + serve_h2_conn(mut transport, mut handler_iface) or {} + }() + + mut conn := new_h2_conn(client_end) + resp := conn.do(H2ClientRequest{ authority: 'h.example' }) or { + assert false, 'client do() failed: ${err}' + return + } + assert resp.status == 404 + assert resp.body.bytestr() == 'status 404' +} + +struct BigBodyHandler { + size int +} + +fn (mut h BigBodyHandler) handle(req Request) Response { + return Response{ + status_code: 200 + body: 'x'.repeat(h.size) + } +} + +// FrameReader reads HTTP/2 frames one at a time off a PipeEnd, buffering any +// leftover bytes between frames. +struct FrameReader { +mut: + end &PipeEnd + buf []u8 +} + +fn (mut r FrameReader) next() !H2Frame { + for { + if r.buf.len >= h2_frame_header_len { + hdr := h2_parse_frame_header(r.buf)! + total := h2_frame_header_len + int(hdr.length) + if r.buf.len >= total { + f := h2_parse_frame(hdr, r.buf[h2_frame_header_len..total])! + r.buf = r.buf[total..].clone() + return f + } + } + mut tmp := []u8{len: 4096} + n := r.end.read(mut tmp)! + r.buf << tmp[..n] + } + return error('unreachable') +} + +fn test_h2_server_respects_send_window() { + mut client_end, mut server_end := new_pipe() + mut handler_iface := Handler(BigBodyHandler{ + size: 100 + }) + spawn fn [mut server_end, mut handler_iface] () { + mut transport := H2Transport(server_end) + serve_h2_conn(mut transport, mut handler_iface) or {} + }() + + // Raw client: preface + SETTINGS(initial_window_size = 10) + a GET, then a + // WINDOW_UPDATE(stream 1, +1000). The server must send at most 10 body + // bytes before consuming the WINDOW_UPDATE, then deliver the rest. + mut enc := H2HpackEncoder{} + block := enc.encode([ + H2HeaderField{':method', 'GET'}, + H2HeaderField{':scheme', 'https'}, + H2HeaderField{':authority', 'h.example'}, + H2HeaderField{':path', '/big'}, + ]) + mut out := []u8{} + out << h2_client_preface.bytes() + out << H2Frame(H2SettingsFrame{ + settings: [H2Setting{h2_settings_initial_window_size, 10}] + }).encode() + out << H2Frame(H2HeadersFrame{ + stream_id: 1 + fragment: block + end_headers: true + end_stream: true + }).encode() + out << H2Frame(H2WindowUpdateFrame{ + stream_id: 1 + window_size_increment: 1000 + }).encode() + client_end.write(out) or { + assert false, 'client write failed: ${err}' + return + } + + mut fr := FrameReader{ + end: client_end + } + mut body := []u8{} + mut status := 0 + mut first_data_len := -1 + mut got_end := false + mut dec := H2HpackDecoder{} + for !got_end { + f := fr.next() or { + assert false, 'frame read failed: ${err}' + return + } + match f { + H2HeadersFrame { + for hf in dec.decode(f.fragment) or { []H2HeaderField{} } { + if hf.name == ':status' { + status = hf.value.int() + } + } + if f.end_stream { + got_end = true + } + } + H2DataFrame { + if first_data_len < 0 { + first_data_len = f.data.len + } + body << f.data + if f.end_stream { + got_end = true + } + } + else {} + } + } + assert status == 200 + assert body.len == 100 + // The first DATA frame must not exceed the initial 10-byte window. + assert first_data_len <= 10 +} diff --git a/vlib/net/http/server.v b/vlib/net/http/server.v index d307fd932..c8eb67900 100644 --- a/vlib/net/http/server.v +++ b/vlib/net/http/server.v @@ -50,6 +50,7 @@ pub mut: cert string cert_key string in_memory_verification bool + enable_http2 bool // opt in to HTTP/2 on the TLS listener: advertises ALPN `h2, http/1.1`. Clients that select `h2` are served by the HTTP/2 driver; clients that select `http/1.1` (or send no ALPN) keep the existing HTTP/1.1 path. on_running fn (mut s Server) = unsafe { nil } // Blocking cb. If set, ran by the web server on transitions to its .running state. on_stopped fn (mut s Server) = unsafe { nil } // Blocking cb. If set, ran by the web server on transitions to its .stopped state. diff --git a/vlib/net/http/server_tls_notd_use_openssl.v b/vlib/net/http/server_tls_notd_use_openssl.v index 27701ff85..c4b36984c 100644 --- a/vlib/net/http/server_tls_notd_use_openssl.v +++ b/vlib/net/http/server_tls_notd_use_openssl.v @@ -23,11 +23,17 @@ fn (mut s Server) listen_and_serve_tls() { s.addr } + // When HTTP/2 is enabled, advertise ALPN `h2, http/1.1` on the listener. + // Clients that select `h2` are dispatched to the HTTP/2 driver after the + // handshake; clients that select `http/1.1` (or send no ALPN extension) + // keep the existing HTTP/1.1 worker path. + alpn := if s.enable_http2 { ['h2', 'http/1.1'] } else { []string{} } mut listener := mbedtls.new_ssl_listener(addr, mbedtls.SSLConnectConfig{ cert: s.cert cert_key: s.cert_key in_memory_verification: s.in_memory_verification validate: false // accept any client; servers don't verify clients by default + alpn_protocols: alpn }) or { eprintln('Listening TLS on ${addr} failed, err: ${err}') return @@ -103,6 +109,16 @@ fn (mut w TlsHandlerWorker) handle_conn(mut conn mbedtls.SSLConn) { defer { conn.shutdown() or {} } + // If the TLS handshake negotiated HTTP/2 via ALPN, switch to the HTTP/2 + // driver; otherwise fall through to the existing HTTP/1.1 path unchanged. + if conn.negotiated_alpn() == 'h2' { + serve_h2_conn(mut conn, mut w.handler) or { + $if debug { + eprintln('h2 server error: ${err}') + } + } + return + } mut reader := io.new_buffered_reader(reader: conn) defer { unsafe { diff --git a/vlib/net/http/server_tls_test.v b/vlib/net/http/server_tls_test.v index 217ee92fa..50bdf9100 100644 --- a/vlib/net/http/server_tls_test.v +++ b/vlib/net/http/server_tls_test.v @@ -75,3 +75,66 @@ fn test_server_tls_round_trip() { assert resp.status_code == 200 assert resp.body == 'tls hello /hello' } + +fn test_server_tls_h2_negotiation() { + $if use_openssl ? { + 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 + } + mut srv := &http.Server{ + addr: '127.0.0.1:${port}' + cert: server_tls_cert + cert_key: server_tls_key + in_memory_verification: true + enable_http2: true + handler: EchoHandler{} + show_startup_message: false + } + spawn srv.listen_and_serve() + srv.wait_till_running() or { + srv.close() + assert false, 'server failed to start: ${err}' + return + } + defer { + srv.close() + } + time.sleep(50 * time.millisecond) + + // Client opts into HTTP/2; server must select `h2` via ALPN and serve the + // request through its HTTP/2 driver. + resp := http.fetch( + url: 'https://127.0.0.1:${port}/h2' + enable_http2: true + validate: false + ) or { + assert false, 'h2 fetch failed: ${err}' + return + } + assert resp.version() == .v2_0 + assert resp.status_code == 200 + assert resp.body == 'tls hello /h2' + + // Without enable_http2 on the client, the server must keep speaking + // HTTP/1.1 to the same listener. + resp_h1 := http.fetch( + url: 'https://127.0.0.1:${port}/h1' + validate: false + ) or { + assert false, 'h1 fetch failed: ${err}' + return + } + assert resp_h1.version() == .v1_1 + assert resp_h1.status_code == 200 +} -- 2.39.5