From 0ff44dc4627abf904dca7a3a2e99b17b823ebe4c Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Sun, 7 Jun 2026 09:22:09 -0400 Subject: [PATCH] net.http: add TLS termination to Server (HTTPS support) (#27373) net.http.Server has been plain-HTTP only. Add TLS termination so an HTTP/1.1 server can listen on https:// directly, without a separate reverse proxy. - Server gains three opt-in fields: `cert`, `cert_key`, and `in_memory_verification`, mirroring the client-side SSL config naming. When both `cert` and `cert_key` are set, `listen_and_serve` delegates to `listen_and_serve_tls`, which uses the mbedtls SSL listener to accept TLS connections, hands them off to a separate worker pool, and serves HTTP/1.1 requests over those TLS sockets with the existing Handler interface. - New default port `default_https_server_port = 9043` for HTTPS listeners. - The TLS path is provided only on the default mbedtls backend (server_tls_notd_use_openssl.v). On `-d use_openssl`, a matching server_tls_d_use_openssl.v provides a clear-error stub at runtime so the module builds and existing plain-HTTP servers keep working; an OpenSSL server listener is a follow-up. - Existing plain-HTTP behavior is completely unchanged when `cert` / `cert_key` are not set: no new code on that path, same Handler contract, same workers. Hermetic test: spins up a local TLS Server with an in-memory cert/key, exercises it via http.fetch(validate: false), and asserts the 200 response body. The test skips itself under -d use_openssl with a clear message. The full vlib/net/http suite is green on both backends, and the TLS test passes under -W -cstrict -cc clang. Co-authored-by: Richard Wheeler Co-authored-by: Claude Opus 4.7 --- vlib/net/http/server.v | 17 ++ vlib/net/http/server_tls_d_use_openssl.v | 13 ++ vlib/net/http/server_tls_notd_use_openssl.v | 172 ++++++++++++++++++++ vlib/net/http/server_tls_test.v | 77 +++++++++ 4 files changed, 279 insertions(+) create mode 100644 vlib/net/http/server_tls_d_use_openssl.v create mode 100644 vlib/net/http/server_tls_notd_use_openssl.v create mode 100644 vlib/net/http/server_tls_test.v diff --git a/vlib/net/http/server.v b/vlib/net/http/server.v index 703966ce9..d307fd932 100644 --- a/vlib/net/http/server.v +++ b/vlib/net/http/server.v @@ -25,6 +25,8 @@ mut: pub const default_server_port = 9009 +pub const default_https_server_port = 9043 + pub struct Server { mut: state ServerStatus = .closed @@ -39,6 +41,16 @@ pub mut: max_keep_alive_requests int = 100 // max requests per keep-alive connection (0 = unlimited) listener net.TcpListener + // TLS termination: when both `cert` and `cert_key` are set, the server + // accepts HTTPS connections instead of plain HTTP. With + // `in_memory_verification = true`, `cert` and `cert_key` are PEM strings; + // otherwise they are filesystem paths. Currently implemented on the + // default mbedtls backend; building with `-d use_openssl` reports a clear + // runtime error from listen_and_serve. + cert string + cert_key string + in_memory_verification bool + 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. on_closed fn (mut s Server) = unsafe { nil } // Blocking cb. If set, ran by the web server on transitions to its .closed state. @@ -53,6 +65,11 @@ pub fn (mut s Server) listen_and_serve() { eprintln('Server handler not set, using debug handler') } + if s.cert != '' && s.cert_key != '' { + s.listen_and_serve_tls() + return + } + mut l := s.listener.addr() or { eprintln('Failed getting listener address, err: ${err}') return diff --git a/vlib/net/http/server_tls_d_use_openssl.v b/vlib/net/http/server_tls_d_use_openssl.v new file mode 100644 index 000000000..491428f1a --- /dev/null +++ b/vlib/net/http/server_tls_d_use_openssl.v @@ -0,0 +1,13 @@ +// 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 + +// TLS termination for net.http.Server is currently provided by the default +// mbedtls backend. Under `-d use_openssl`, the OpenSSL backend does not yet +// expose a server-side listener; this file replaces the implementation in +// `server_tls_notd_use_openssl.v` with a stub that reports a clear error. + +fn (mut s Server) listen_and_serve_tls() { + eprintln('net.http.Server: TLS termination is not supported on -d use_openssl yet; remove -d use_openssl or omit cert/cert_key to fall back to plain HTTP') +} diff --git a/vlib/net/http/server_tls_notd_use_openssl.v b/vlib/net/http/server_tls_notd_use_openssl.v new file mode 100644 index 000000000..27701ff85 --- /dev/null +++ b/vlib/net/http/server_tls_notd_use_openssl.v @@ -0,0 +1,172 @@ +// 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 + +import io +import time +import net.mbedtls + +// This file implements TLS termination for net.http.Server on top of the +// mbedtls SSL listener. It is gated to the default TLS backend; the matching +// `server_tls_d_use_openssl.v` provides a clear-error stub when the project is +// built with `-d use_openssl`. + +// listen_and_serve_tls is the TLS counterpart of listen_and_serve. It is +// dispatched to by listen_and_serve when `s.cert` and `s.cert_key` are set. +fn (mut s Server) listen_and_serve_tls() { + // Pick a default port that's distinct from the plain-HTTP default if the + // user hasn't overridden it. + addr := if s.addr == '' || s.addr == ':${default_server_port}' { + ':${default_https_server_port}' + } else { + s.addr + } + + 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 + }) or { + eprintln('Listening TLS on ${addr} failed, err: ${err}') + return + } + defer { + listener.shutdown() or {} + } + s.addr = addr + + ch := chan &mbedtls.SSLConn{cap: s.pool_channel_slots} + mut ws := []thread{cap: s.worker_num} + for wid in 0 .. s.worker_num { + ws << new_tls_handler_worker(wid, ch, s.handler, s.max_keep_alive_requests) + } + + if s.show_startup_message { + println('Listening on https://${s.addr}/') + flush_stdout() + } + + time.sleep(20 * time.millisecond) + s.state = .running + if s.on_running != unsafe { nil } { + s.on_running(mut s) + } + for s.state == .running { + mut conn := listener.accept() or { + if s.state != .running { + break + } + $if debug { + eprintln('TLS accept failed: ${err}; skipping') + } + continue + } + if s.read_timeout > 0 { + conn.set_read_timeout(s.read_timeout) + } + ch <- conn + } + if s.state == .stopped { + s.close() + } +} + +// TlsHandlerWorker serves HTTP/1.1 requests on TLS-wrapped connections. +struct TlsHandlerWorker { + id int + ch chan &mbedtls.SSLConn + max_keep_alive_requests int +pub mut: + handler Handler +} + +fn new_tls_handler_worker(wid int, ch chan &mbedtls.SSLConn, handler Handler, max_keep_alive_requests int) thread { + mut w := &TlsHandlerWorker{ + id: wid + ch: ch + handler: handler + max_keep_alive_requests: max_keep_alive_requests + } + return spawn w.process_requests() +} + +fn (mut w TlsHandlerWorker) process_requests() { + for { + mut conn := <-w.ch or { break } + w.handle_conn(mut conn) + } +} + +fn (mut w TlsHandlerWorker) handle_conn(mut conn mbedtls.SSLConn) { + defer { + conn.shutdown() or {} + } + mut reader := io.new_buffered_reader(reader: conn) + defer { + unsafe { + reader.free() + } + } + + mut request_count := 0 + for { + mut req := parse_request(mut reader) or { + if err !is io.Eof { + $if debug { + eprintln('error parsing TLS request: ${err}') + } + } + return + } + request_count++ + // `conn.ip` is the peer's IPv4 address as populated by mbedtls' + // accept(); blank for IPv6, which is acceptable for keep-alive logic. + if conn.ip != '' { + req.header.add_custom('Remote-Addr', conn.ip) or {} + } + + mut resp := w.handler.handle(req) + normalize_server_response(mut resp, req) + + if !resp.header.contains(.content_length) { + resp.header.set(.content_length, '${resp.body.len}') + } + + max_reached := w.max_keep_alive_requests > 0 && request_count >= w.max_keep_alive_requests + req_conn := (req.header.get(.connection) or { '' }).to_lower() + resp_conn := (resp.header.get(.connection) or { '' }).to_lower() + keep_alive := if max_reached { + false + } else if resp_conn == 'close' { + false + } else if resp_conn == 'keep-alive' { + true + } else if req_conn == 'close' { + false + } else if req_conn == 'keep-alive' { + true + } else { + req.version == .v1_1 + } + if max_reached || !resp.header.contains(.connection) { + if keep_alive { + resp.header.set(.connection, 'keep-alive') + } else { + resp.header.set(.connection, 'close') + } + } + + conn.write(resp.bytes()) or { + $if debug { + eprintln('error sending TLS response: ${err}') + } + return + } + + if !keep_alive { + return + } + } +} diff --git a/vlib/net/http/server_tls_test.v b/vlib/net/http/server_tls_test.v new file mode 100644 index 000000000..217ee92fa --- /dev/null +++ b/vlib/net/http/server_tls_test.v @@ -0,0 +1,77 @@ +// Hermetic TLS-termination test for net.http.Server: spin up a local HTTPS +// server with an in-memory cert/key, hit it with http.fetch (validate: false), +// and assert the round-trip. + +module main + +import net +import net.http +import time + +const server_tls_cert = '-----BEGIN CERTIFICATE-----\nMIIEOTCCAyECFG64Q2g46jZb3kRbDOJWX/BwjSp6MA0GCSqGSIb3DQEBCwUAMEUx\nCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl\ncm5ldCBXaWRnaXRzIFB0eSBMdGQwIBcNMjMwODAyMTcyOTQyWhgPMjA1MDEyMTcx\nNzI5NDJaMGsxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRQwEgYD\nVQQHDAtMb3MgQW5nZWxlczEdMBsGA1UECgwUQ2F0YWx5c3QgRGV2ZWxvcG1lbnQx\nEjAQBgNVBAMMCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC\nggIBALqAI4fqUi+QBVWcsXglouLdOML5+w0+1hSR1KdO0Q5XPdQAs/yYWJ+KUkDw\nG++rfy9DUPq7FNRBVurXQkcAtn6gXdllGUSjwUiDo/N4mMOyS/2sufBuaeww7jVi\nrppH+zwP1tUnjRd6khl6bi1Ian9VSzr3Iy9CkXIg1GU4CPXkOydLeoQfepXxWoK1\nOUNwT3VKC/stAfY3j/NIIeiJYkyuRGFCkxn/BUjN+AsXiTugRcYKEFHdIPkOuCXp\nYbhf+lLsczpxCs3rdZG9b/N6mEDCzXTmeHkmsjdPTf+1k5DZZvKzVBBrgdxCgBb7\n5RwjF5v9WmnIc33wWgfJC6FaUzj9NYxYUbPHD+jTz0rJB/jj4u/xJlM/e5NRmXdW\n70pOMKXtWjRSolLOFIPKLY1qs3KMTAZxKKWPDDF7WlMJxMRt7nnnks5yw43Nog4C\njDLk1ZgETnPpLgo3jbmJdIv+OHKTJrBlVvDq7VTyixCoS5G8KoOmyQJhaXG6NwE2\niVhH5JIKgzgCfetfDsnjxqJ/qtrFXPa8FF2TsomD0NK/GZmIcs+9OeVB75Jn5uhF\nfLHScpiTbuu5w3P/LI/MqihLRB6RRNnRzPH8fIg5bYC9b770ta/8GcFRuYE8t+UR\nGtqXJoIKixbDlqV54kal8FQzYzhETf9+NM6Kb/lKEfG/pslvAgMBAAEwDQYJKoZI\nhvcNAQELBQADggEBALI3uNiNO0QE1brA3QYFK+d9ZroB72NrJ0UNkzYHDg2Fc6xg\n4aVVfaxY08+TmKc0JlMOW+pUxeCW/+UBSngdQiR9EE9xm0k0XIrAsy9RXxRvEtPu\nM1VI2h7ayp1Y2BrnQinevTSgtqLRyS1VbOFRl1FiyVvinw2I0KsDdAMNevAPXcOa\nQ8pUgUq6f56DkhocQaj+hxD/uV8HryNxuoSXnPhvfTN3z4YRGzsaWevJ9EYJliOM\n+XugcqfFJ+W7/QCEcAHCL+Bw6OydG5NFORr3p57PXjjcL/uKmxPBrWg2Bz6uT4uR\nMhj0zttiFHLAt9jGfyk6W57UNUja1e1ggftJJhs=\n-----END CERTIFICATE-----\n' + +const server_tls_key = '-----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----\n' + +struct EchoHandler { +mut: + last_path string +} + +fn (mut h EchoHandler) handle(req http.Request) http.Response { + h.last_path = req.url + return http.Response{ + status_code: 200 + body: 'tls hello ${req.url}' + } +} + +fn pick_port() !int { + mut l := net.listen_tcp(.ip, '127.0.0.1:0')! + port := l.addr()!.port()! + l.close()! + return port +} + +fn test_server_tls_round_trip() { + $if use_openssl ? { + // TLS termination for net.http.Server is not yet supported on the + // OpenSSL backend; the listener stub reports a clear runtime error and + // the test is skipped here so the suite stays green under + // `-d use_openssl`. + eprintln('skipping: TLS server not implemented for -d use_openssl 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 + 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() + } + // Give the listener a beat to come up. + time.sleep(50 * time.millisecond) + + resp := http.fetch( + url: 'https://127.0.0.1:${port}/hello' + validate: false + ) or { + assert false, 'fetch failed: ${err}' + return + } + assert resp.status_code == 200 + assert resp.body == 'tls hello /hello' +} -- 2.39.5