diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 680d47194..ae23380f1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -98,6 +98,18 @@ jobs: if: matrix.os == 'ubuntu-latest' name: Run docker tests + http3: + name: HTTP/3 (ngtcp2 on OpenSSL) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install Rust + run: rustup update stable && rustup default stable + - run: cargo test --no-run --features http3,static-ssl + - run: cargo run --example http3 --features http3,static-ssl + rustfmt: name: Rustfmt runs-on: ubuntu-latest diff --git a/.github/workflows/update-curl.yml b/.github/workflows/update-curl.yml index 0a113a8f9..4eb706f13 100644 --- a/.github/workflows/update-curl.yml +++ b/.github/workflows/update-curl.yml @@ -1,7 +1,8 @@ -# This workflow automates some of the process of updating curl-sys to the latest -# version of curl upstream. +# This workflow automates some of the process of updating the bundled +# libraries (curl, and ngtcp2 and nghttp3 for the http3 feature) to their +# latest upstream releases. One pull request per library. -name: Update curl +name: Update bundled libraries on: schedule: @@ -14,6 +15,22 @@ jobs: permissions: contents: write pull-requests: write + strategy: + fail-fast: false + matrix: + include: + - name: curl + repo: curl/curl + path: curl-sys/curl + changelog: https://curl.se/ch/TAG.html + - name: ngtcp2 + repo: ngtcp2/ngtcp2 + path: curl-sys/ngtcp2 + changelog: https://github.com/ngtcp2/ngtcp2/releases/tag/TAG + - name: nghttp3 + repo: ngtcp2/nghttp3 + path: curl-sys/nghttp3 + changelog: https://github.com/ngtcp2/nghttp3/releases/tag/TAG steps: - uses: actions/checkout@v7 with: @@ -24,25 +41,27 @@ jobs: env: GH_TOKEN: ${{ secrets.GH_PAT }} run: | - LATEST_TAG=$(gh release view --repo curl/curl --json tagName --jq .tagName) - cd curl-sys/curl + LATEST_TAG=$(gh release view --repo ${{ matrix.repo }} --json tagName --jq .tagName) + cd ${{ matrix.path }} git fetch --tags git checkout $LATEST_TAG echo "tag=$LATEST_TAG" >> $GITHUB_OUTPUT + echo "changelog=${{ matrix.changelog }}" | sed "s/TAG/$LATEST_TAG/" >> $GITHUB_OUTPUT - name: Create pull request uses: peter-evans/create-pull-request@v7 with: token: ${{ secrets.GH_PAT }} - branch: update-curl-to-${{ steps.update.outputs.tag }} - commit-message: "chore: update curl to ${{ steps.update.outputs.tag }}" - title: "Update curl to ${{ steps.update.outputs.tag }}" + branch: update-${{ matrix.name }}-to-${{ steps.update.outputs.tag }} + commit-message: "chore: update ${{ matrix.name }} to ${{ steps.update.outputs.tag }}" + title: "Update ${{ matrix.name }} to ${{ steps.update.outputs.tag }}" body: | - Update the bundled curl submodule to version ${{ steps.update.outputs.tag }}. + Update the bundled ${{ matrix.name }} submodule to version ${{ steps.update.outputs.tag }}. - Changelog: + Changelog: <${{ steps.update.outputs.changelog }}> This PR was created in an automated fashion. Additional changes may - be required to get the new version of curl to work with our + be required to get the new version of ${{ matrix.name }} to work with our `build.rs` script. + # one label for all bundled libraries; it already exists in the repo labels: curl-update diff --git a/.gitmodules b/.gitmodules index fba1ad48d..d9ce04edb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ [submodule "curl-sys/curl"] path = curl-sys/curl url = https://github.com/curl/curl.git +[submodule "curl-sys/nghttp3"] + path = curl-sys/nghttp3 + url = https://github.com/ngtcp2/nghttp3 +[submodule "curl-sys/ngtcp2"] + path = curl-sys/ngtcp2 + url = https://github.com/ngtcp2/ngtcp2 diff --git a/Cargo.toml b/Cargo.toml index fc46a6e66..2d6fd72b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ default = ["ssl"] ssl = ["openssl-sys", "openssl-probe", "curl-sys/ssl"] # OpenSSL/system TLS backend mesalink = ["curl-sys/mesalink"] # MesaLink TLS backend http2 = ["curl-sys/http2"] +http3 = ["curl-sys/http3", "ssl", "static-curl"] spnego = ["curl-sys/spnego"] rustls = ["curl-sys/rustls"] static-curl = ["curl-sys/static-curl"] diff --git a/README.md b/README.md index d0911bea9..5a55e5337 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ with various Cargo features: Note that Rustls support is experimental within Curl itself and may have significant bugs, so we don't offer any sort of stability guarantee with this feature. - `http2`: Enable HTTP/2 support via libnghttp2. Disabled by default. +- `http3`: Enable HTTP/3 via libcurl's ngtcp2 backend, built from bundled ngtcp2 and nghttp3 sources on OpenSSL's QUIC API. Implies `ssl` and `static-curl`; needs OpenSSL 3.5 or newer, which `static-ssl` provides where the system's is older. Unix only. Disabled by default. - `static-curl`: Use a bundled libcurl version and statically link to it. Disabled by default. - `apple-sectrust`: Use Apple SecTrust for certificate verification with bundled libcurl and OpenSSL on Apple platforms. Implies `ssl` and `static-curl`. Disabled by default. - `static-ssl`: Use a bundled OpenSSL version and statically link to it. Only applies on platforms that use OpenSSL. Disabled by default. diff --git a/curl-sys/Cargo.toml b/curl-sys/Cargo.toml index b6663832c..ff4cf9427 100644 --- a/curl-sys/Cargo.toml +++ b/curl-sys/Cargo.toml @@ -11,7 +11,19 @@ documentation = "https://docs.rs/curl-sys" readme = "../README.md" categories = ["external-ffi-bindings"] edition = "2018" -exclude = ["curl/docs/", "curl/tests/"] +exclude = [ + "curl/docs/", + "curl/tests/", + "nghttp3/doc/", + "nghttp3/examples/", + "nghttp3/fuzz/", + "nghttp3/tests/", + "ngtcp2/doc/", + "ngtcp2/examples/", + "ngtcp2/fuzz/", + "ngtcp2/tests/", + "ngtcp2/third-party/", +] [badges.maintenance] status = "passively-maintained" @@ -47,6 +59,7 @@ pkg-config = "0.3.3" default = ["ssl"] ssl = ["openssl-sys"] http2 = ["libnghttp2-sys"] +http3 = ["ssl", "static-curl"] mesalink = [] rustls = ["rustls-ffi"] static-curl = [] diff --git a/curl-sys/build.rs b/curl-sys/build.rs index 7d028d410..f2c1f06ce 100644 --- a/curl-sys/build.rs +++ b/curl-sys/build.rs @@ -35,6 +35,8 @@ fn write_windows_config(dst: &Path) { fn main() { println!("cargo:rerun-if-changed=curl"); + println!("cargo:rerun-if-changed=nghttp3"); + println!("cargo:rerun-if-changed=ngtcp2"); println!( "cargo:rustc-check-cfg=cfg(\ libcurl_vendored,\ @@ -350,6 +352,10 @@ fn main() { // Configure TLS backend. Since Cargo does not support mutually exclusive // features, make sure we only compile one vtls. + if cfg!(feature = "http3") && (cfg!(feature = "rustls") || windows) { + // not a hard error, so that --all-features still builds + println!("cargo:warning=the http3 feature needs libcurl's OpenSSL backend on a unix target; ignored"); + } if cfg!(feature = "rustls") { cfg.define("USE_RUSTLS", None) .file("curl/lib/vtls/cipher_suite.c") @@ -404,6 +410,9 @@ fn main() { if let Some(path) = env::var_os("DEP_OPENSSL_INCLUDE") { cfg.include(path); } + if cfg!(feature = "http3") { + http3(&mut cfg, &dst, &target); + } } } @@ -641,3 +650,118 @@ fn curl_config_reports_http2() -> bool { true } + +/// HTTP/3 through libcurl's ngtcp2 backend on OpenSSL's QUIC API (3.5+). +/// ngtcp2 and nghttp3 are built from the bundled sources like libcurl is, +/// with `cc` and a generated `version.h` instead of their cmake setups. +fn http3(curl: &mut cc::Build, dst: &Path, target: &str) { + let version = env::var("DEP_OPENSSL_VERSION_NUMBER") + .ok() + .and_then(|v| u64::from_str_radix(&v, 16).ok()) + .unwrap_or(0); + if version < 0x3_05_00_00_0 { + panic!( + "the http3 feature needs OpenSSL 3.5 or newer for its QUIC API \ + (found 0x{version:x}); the static-ssl feature builds one from source" + ); + } + let openssl_include = env::var_os("DEP_OPENSSL_INCLUDE").map(PathBuf::from); + + // nghttp3/version.h and ngtcp2/version.h from their cmake templates + let generated = dst.join("quic-include"); + for lib in ["nghttp3", "ngtcp2"] { + let cmake = fs::read_to_string(format!("{lib}/CMakeLists.txt")).unwrap(); + let version = cmake + .lines() + .find_map(|l| l.trim().strip_prefix(&format!("project({lib} VERSION "))) + .and_then(|l| l.split_whitespace().next()) + .expect("project version in CMakeLists.txt"); + let num = version + .split('.') + .map(|p| p.parse::().unwrap()) + .fold(0, |acc, p| (acc << 8) | p); + let template = + fs::read_to_string(format!("{lib}/lib/includes/{lib}/version.h.in")).unwrap(); + let out = generated.join(lib); + fs::create_dir_all(&out).unwrap(); + fs::write( + out.join("version.h"), + template + .replace("@PACKAGE_VERSION@", version) + .replace("@PACKAGE_VERSION_NUM@", &format!("0x{num:06x}")), + ) + .unwrap(); + } + + let c_files = |dir: &str| { + let mut files: Vec = fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().is_some_and(|e| e == "c")) + .collect(); + files.sort(); + files + }; + // what the cmake configure would have found on these platforms + let platform = |build: &mut cc::Build| { + build + .define("HAVE_ARPA_INET_H", None) + .define("HAVE_NETINET_IN_H", None) + .define("HAVE_UNISTD_H", None) + .warnings(false); + if target.contains("linux") || target.contains("android") { + build + .define("HAVE_ENDIAN_H", None) + .define("HAVE_BYTESWAP_H", None) + .define("HAVE_DECL_BE64TOH", "1") + .define("HAVE_DECL_BSWAP_64", "1"); + } else if target.contains("bsd") { + build + .define("HAVE_SYS_ENDIAN_H", None) + .define("HAVE_DECL_BE64TOH", "1"); + } + }; + + let mut nghttp3 = cc::Build::new(); + platform(&mut nghttp3); + nghttp3 + .include("nghttp3/lib/includes") + .include("nghttp3/lib") + .include(&generated) + .define("NGHTTP3_STATICLIB", None) + .files(c_files("nghttp3/lib")) + .file("nghttp3/lib/sfparse/sfparse.c") + .compile("nghttp3"); + + let mut ngtcp2 = cc::Build::new(); + platform(&mut ngtcp2); + ngtcp2 + .include("ngtcp2/lib/includes") + .include("ngtcp2/lib") + .include("ngtcp2/crypto/includes") + .include("ngtcp2/crypto") + .include(&generated) + .define("NGTCP2_STATICLIB", None) + .files(c_files("ngtcp2/lib")) + .file("ngtcp2/crypto/shared.c") + .file("ngtcp2/crypto/ossl/ossl.c"); + if let Some(path) = &openssl_include { + ngtcp2.include(path); + } + ngtcp2.compile("ngtcp2"); + + curl.define("USE_NGTCP2", None) + .define("USE_NGHTTP3", None) + .define("OPENSSL_QUIC_API2", None) + .define("NGTCP2_STATICLIB", None) + .define("NGHTTP3_STATICLIB", None) + .define("HAVE_SENDMSG", None) + .define("HAVE_NETINET_UDP_H", None) + .include("nghttp3/lib/includes") + .include("ngtcp2/lib/includes") + .include("ngtcp2/crypto/includes") + .include(&generated) + .files(c_files("curl/lib/vquic")); + // no HAVE_SENDMMSG: its struct mmsghdr and IOV_MAX need _GNU_SOURCE, + // which this build does not set; sendmsg is the portable path +} diff --git a/curl-sys/lib.rs b/curl-sys/lib.rs index 5e5b10c28..573365016 100644 --- a/curl-sys/lib.rs +++ b/curl-sys/lib.rs @@ -677,6 +677,7 @@ pub const CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE: c_int = 5; /// Use HTTP/3, fallback to HTTP/2 or HTTP/1 if needed. /// (Added in CURL 7.66.0) pub const CURL_HTTP_VERSION_3: c_int = 30; +pub const CURL_HTTP_VERSION_3ONLY: c_int = 31; // Note that the type here is wrong, it's just intended to just be an enum. pub const CURL_SSLVERSION_DEFAULT: __enum_ty = 0; diff --git a/curl-sys/nghttp3 b/curl-sys/nghttp3 new file mode 160000 index 000000000..dbfc24286 --- /dev/null +++ b/curl-sys/nghttp3 @@ -0,0 +1 @@ +Subproject commit dbfc24286138cb0b6490160e7ca87fe1ce6722a0 diff --git a/curl-sys/ngtcp2 b/curl-sys/ngtcp2 new file mode 160000 index 000000000..f9e9ff01a --- /dev/null +++ b/curl-sys/ngtcp2 @@ -0,0 +1 @@ +Subproject commit f9e9ff01ad2c8116bc09de4f644b0028a61486a6 diff --git a/examples/http3.rs b/examples/http3.rs new file mode 100644 index 000000000..26e494fab --- /dev/null +++ b/examples/http3.rs @@ -0,0 +1,30 @@ +//! Fetches a page over HTTP/3 and nothing else: `V3Only` makes libcurl +//! fail instead of falling back to HTTP/2 or HTTP/1.1. +//! +//! Needs the `http3` feature (and `static-ssl` where the system OpenSSL is +//! older than 3.5): +//! +//! cargo run --example http3 --features http3,static-ssl + +use curl::easy::{Easy, HttpVersion}; +use curl::Version; +use std::io::{stdout, Write}; + +fn main() -> Result<(), curl::Error> { + let version = Version::get(); + assert!( + version.feature_http3(), + "this libcurl has no HTTP/3: {}", + version.version() + ); + let mut easy = Easy::new(); + easy.url("https://cloudflare-quic.com/")?; + easy.http_version(HttpVersion::V3Only)?; + easy.write_function(|data| { + stdout().write_all(data).unwrap(); + Ok(data.len()) + })?; + easy.perform()?; + eprintln!("\n{} over HTTP/3", easy.response_code()?); + Ok(()) +} diff --git a/src/easy/handler.rs b/src/easy/handler.rs index d9aa7b6ff..af7436dfc 100644 --- a/src/easy/handler.rs +++ b/src/easy/handler.rs @@ -462,6 +462,10 @@ pub enum HttpVersion { /// /// (Added in CURL 7.66.0) V3 = curl_sys::CURL_HTTP_VERSION_3 as isize, + + /// Use HTTP/3 and fail if that is not possible, no fallback to earlier + /// versions. (Added in CURL 7.88.0) + V3Only = curl_sys::CURL_HTTP_VERSION_3ONLY as isize, } /// Possible values to pass to the `ssl_version` and `ssl_min_max_version` method.