diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f8eed..729b5d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [0.7.1] - 2026-10-14 + +### Fixed + +- Set DHTTP client and server QUIC idle timeouts to five minutes, allowing silent long-running handlers to finish without extra heartbeat requests. Both peers must adopt the setting; QUIC still negotiates the shorter advertised timeout. +- Standardize the identity OCSP cache at `ssl/stapling.ocsp` for dhttp-home, Endpoint loads and SDK reloads; update credential fixtures and transport tests to use the same path. +- Stop reading the old `ssl/ocsp.der` cache. Offline deployments must prepare a valid staple at the new path before loading an identity; online OCSP preparation validates or refreshes the new cache. + +### Release versions + +| Package | Previous published version | Prepared version | +| --- | --- | --- | +| `dhttp` | 0.7.0 | 0.7.1 | +| `dhttp-home` | 0.6.0 | 0.6.1 | +| `@genmeta/dhttp / dhttpy` | 0.7.0 | 0.7.1 | + +`dhttp-access` remains 0.5.0 and `dhttp-log` remains 0.2.0. +The SDK bridge version is aligned; multi-platform npm/PyPI publishing remains separate. + ## [0.7.0] - 2026-10-12 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 1c731be..a1bb130 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1117,7 +1117,7 @@ dependencies = [ [[package]] name = "dhttp" -version = "0.7.0" +version = "0.7.1" dependencies = [ "async-stream", "bytes", @@ -1174,7 +1174,7 @@ dependencies = [ [[package]] name = "dhttp-api" -version = "0.7.0" +version = "0.7.1" dependencies = [ "async-stream", "bytes", @@ -1202,7 +1202,7 @@ dependencies = [ [[package]] name = "dhttp-home" -version = "0.6.0" +version = "0.6.1" dependencies = [ "der 0.7.10", "dhttp-identity", diff --git a/Cargo.toml b/Cargo.toml index 1fb5286..4b4c77f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["dhttp", "home", "access", "log", "api"] [workspace.package] -version = "0.7.0" +version = "0.7.1" edition = "2024" license = "Apache-2.0" repository = "https://github.com/genmeta/dhttp" @@ -51,7 +51,7 @@ qudp = { version = "0.8.1" } qrecovery = { version = "0.7.0" } qtls = { version = "0.1.0" } h3x = { version = "0.7.0" } -dhttp-home = { path = "home", version = "0.6.0", default-features = false } -dhttp = { path = "dhttp", version = "0.7.0" } +dhttp-home = { path = "home", version = "0.6.1", default-features = false } +dhttp = { path = "dhttp", version = "0.7.1" } dhttp-access = { path = "access", version = "0.5.0" } dhttp-log = { path = "log", version = "0.2.0" } diff --git a/README.md b/README.md index 6284212..e6aa305 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Add the library and the dependencies used by these examples: ```toml [dependencies] -dhttp = "0.7.0" +dhttp = "0.7.1" ddns = { package = "dyns", version = "0.8.0", features = ["h3"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } http-body-util = "0.1" @@ -154,7 +154,7 @@ mappings, while the application owns name publication. ## Node.js and Python SDK The native bindings follow the same endpoint model using each language's -standard request, response and asynchronous streaming conventions. The 0.7.0 +standard request, response and asynchronous streaming conventions. The 0.7.1 bindings can be built from [api/](api/README.md); multi-platform npm/PyPI release packaging remains pending. diff --git a/RELEASING.md b/RELEASING.md index fb9de87..fa9fae1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,47 +1,39 @@ -# Release preparation for v0.7.0 +# Release preparation for v0.7.1 | Package | Previous published version | Prepared version | | --- | --- | --- | -| `dhttp` | 0.6.2 | 0.7.0 | -| `dhttp-home` | 0.5.0 | 0.6.0 | -| `dhttp-access` | 0.4.2 | 0.5.0 | -| `dhttp-log` | 0.1.0 | 0.2.0 | -| `@genmeta/dhttp / dhttpy` | 0.6.2 | 0.7.0 | +| `dhttp` | 0.7.0 | 0.7.1 | +| `dhttp-home` | 0.6.0 | 0.6.1 | +| `dhttp-access` | 0.5.0 | unchanged | +| `dhttp-log` | 0.2.0 | unchanged | +| `@genmeta/dhttp / dhttpy` | 0.7.0 | 0.7.1 | + +## Scope and upgrade + +Client and server endpoints advertise a five-minute QUIC idle timeout. This permits five minutes without packets and does not send periodic keepalive traffic. Update both peers to use the full window; an older peer's shorter advertised timeout still applies. Connections created before the upgrade retain their negotiated timeout. + +This patch release standardizes the OCSP cache at `ssl/stapling.ocsp`. Identity loading and SDK reloads use this path; the old `ssl/ocsp.der` cache is no longer read. Offline deployments must prepare a valid staple at the new path before loading an identity. With online identity preparation enabled, the existing OCSP preparation validates or refreshes the new cache. No automatic rename or change to OCSP verification is introduced. + +The SDK bridge and Node.js/Python binding versions are aligned with dhttp. Multi-platform npm/PyPI publishing remains separate from the crates.io workflow. ## Dependency order -dquic → h3x +dquic → h3x → dhttp-home → dhttp -Manifest versions use Cargo's compatible-version requirements, without `=`. -They allow patch updates within the selected 0.x series. External Genmeta -dependencies come from crates.io; no sibling checkout or Git patch is required. -The dquic TLS backend is the published qrustls 0.23.45 package, used as `rustls` -where the application exchanges TLS types with qtls. +Manifest versions use Cargo's compatible-version requirements, without `=`. External Genmeta dependencies come from crates.io; no sibling checkout or Git patch is required. The TLS backend remains qrustls 0.23.45, used as `rustls` where the application exchanges TLS types with qtls. ## Validation and lockfiles -This PR is release preparation. Unpublished upstream versions can make CI fail; -such failures do not confirm compilation, tests or package verification. -For tracked lockfiles, run `cargo generate-lockfile` after upstream publication, -review and commit the resulting registry lockfile before tagging. -Run Cargo from a checkout outside directories containing ancestor path patches. -Never commit a lockfile generated with local transport path overrides as the -registry release lockfile. Re-run the normal CI checks and publish dry run after -upstream publication and lockfile refresh; merge and tag only that checked commit. +The workspace Cargo.lock is tracked for locked platform checks and publication. Refresh it from the published registry graph outside directories containing ancestor path patches. Preserve unrelated dependency versions; never commit local transport overrides as the release lockfile. -## Publication +Android builds require qudp 0.8.1 from the dquic v0.8.1 patch release. Retain libc 0.2.189 while nix is incompatible with the Darwin constants removed from iOS in libc 0.2.190. + +Before merging and tagging, require the existing formatting, Clippy, test, platform and publish dry-run workflows to pass on the release commit. Local manifest, lockfile and package-content checks do not replace compilation or runtime tests. The dry run handles the new home and dhttp packages together; publication installs dependencies first. -Planned tag: `v0.7.0`. Release through the repository's GitHub Actions workflow. -Use an annotated tag with the changelog entry as its release notes. Actual -publication and production deployment remain separate from this preparation PR. +## Publication -Publish crates in order: dhttp-home, dhttp-access, dhttp-log, dhttp. -The dhttp-api bridge is not a crates.io package; Node.js and Python bindings -use version 0.7.0. The removed dhttp-identity package is not republished. +Planned tag: `v0.7.1`. After the preparation PR is merged and checks pass, use an annotated tag with the changelog entry as its release notes. Tag-triggered GitHub Actions publishes crates and creates the GitHub Release; preparation does not publish or deploy. -The workspace Cargo.lock is tracked for locked platform checks and publication. -Refresh it from the published registry graph before each release. +Keep the existing publication order: dhttp-home, dhttp-access, dhttp-log, dhttp. The workflow skips the unchanged access/log versions already on crates.io, so this release publishes dhttp-home 0.6.1 before dhttp 0.7.1. -Android builds require qudp 0.8.1 from the dquic v0.8.1 patch release. -The registry lockfile retains libc 0.2.189 while nix is incompatible with the -Darwin constants removed from iOS in libc 0.2.190. +The dhttp-api bridge is not a crates.io package; Node.js and Python bindings use version 0.7.1. The removed dhttp-identity package is not republished. diff --git a/api/Cargo.toml b/api/Cargo.toml index c26e796..38fa61e 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dhttp-api" -version = "0.7.0" +version = "0.7.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/api/README.md b/api/README.md index a72e471..bd6c9b1 100644 --- a/api/README.md +++ b/api/README.md @@ -73,7 +73,7 @@ peers 可增补或更新;addresses() 返回当前本地绑定,用于显式 默认 H3 DNS、mDNS 和名称发布维护仍待接入,因此当前不是完整的开箱即用发现版本。 Native Node build 产物是本机构建的 dhttp.node;npm pack 可用于本机安装验证。 -多平台 npm 二进制包、其他 Python wheel 平台和发布 CI 尚未恢复。当前绑定版本为 0.7.0;多平台 npm/PyPI 发布仍待完成。 +多平台 npm 二进制包、其他 Python wheel 平台和发布 CI 尚未恢复。当前绑定版本为 0.7.1;多平台 npm/PyPI 发布仍待完成。 ## 流式请求 diff --git a/api/package.json b/api/package.json index d484625..a30df20 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@genmeta/dhttp", - "version": "0.7.0", + "version": "0.7.1", "description": "DHTTP HTTP/3 SDK", "license": "Apache-2.0", "main": "js/index.js", diff --git a/api/tests/quic_roundtrip.rs b/api/tests/quic_roundtrip.rs index ff66ad3..f51dc44 100644 --- a/api/tests/quic_roundtrip.rs +++ b/api/tests/quic_roundtrip.rs @@ -151,7 +151,7 @@ async fn acceptance() { assert!(matches!(second, Err(error) if error.code == ErrorCode::AlreadyListening)); // A failed explicit-path reload retains the previous signing capability. let original = alice.local_authority().await.unwrap().unwrap(); - let ocsp_path = root.join("alice/ssl/ocsp.der"); + let ocsp_path = root.join("alice/ssl/stapling.ocsp"); let ocsp = fs::read(&ocsp_path).unwrap(); fs::write(&ocsp_path, b"invalid OCSP").unwrap(); assert!(alice.reload().await.is_err()); diff --git a/dhttp/src/network.rs b/dhttp/src/network.rs index e9e5d0b..db61948 100644 --- a/dhttp/src/network.rs +++ b/dhttp/src/network.rs @@ -103,8 +103,8 @@ impl DhttpNetwork { scopes: Scopes, service: BoxService, ) -> Result { - let mut quic = qconn::QuicEndpoint::from(endpoint.identity.clone()); - quic.set_alpn(vec![h3x::ALPN.to_vec()]); + let quic = + crate::transport::configure_quic(qconn::QuicEndpoint::from(endpoint.identity.clone()))?; let name: Arc = Arc::from(endpoint.name()); { let mut listeners = self.listeners.lock().unwrap(); @@ -203,8 +203,9 @@ impl Endpoint { let registered = qconn::ServerRegistry::global() .get(self.name()) .ok_or_else(|| std::io::Error::other("listener TLS registration disappeared"))?; - let mut quic = qconn::QuicEndpoint::from(replacement.identity.clone()); - quic.set_alpn(vec![h3x::ALPN.to_vec()]); + let mut quic = crate::transport::configure_quic(qconn::QuicEndpoint::from( + replacement.identity.clone(), + ))?; for (id, value) in registered.server_parameters.iter() { quic.set_parameters(qbase::role::Role::Server, *id, value.clone()) .map_err(std::io::Error::other)?; diff --git a/dhttp/src/network/connection.rs b/dhttp/src/network/connection.rs index b0c92ac..7b53b49 100644 --- a/dhttp/src/network/connection.rs +++ b/dhttp/src/network/connection.rs @@ -97,13 +97,13 @@ pub(super) async fn connect( tracing::trace!(local = ?key.names().0, remote = ?key.names().1, "outgoing QUIC handshake started"); let (local, remote, connection) = match local { Some(endpoint) => { - let mut quic = qconn::QuicEndpoint::from(endpoint.identity.clone()); - quic.set_alpn(vec![h3x::ALPN.to_vec()]); + let quic = crate::transport::configure_quic(qconn::QuicEndpoint::from( + endpoint.identity.clone(), + ))?; quic.connect(remote.to_string()).await? } None => { - let mut quic = qconn::QuicEndpoint::anonymous(); - quic.set_alpn(vec![h3x::ALPN.to_vec()]); + let quic = crate::transport::configure_quic(qconn::QuicEndpoint::anonymous())?; quic.connect(remote.to_string()).await? } }; diff --git a/dhttp/src/transport.rs b/dhttp/src/transport.rs index d02bc62..94d11b5 100644 --- a/dhttp/src/transport.rs +++ b/dhttp/src/transport.rs @@ -5,6 +5,7 @@ use std::{ pin::Pin, sync::Arc, task::{Context, Poll}, + time::Duration, }; use bytes::Bytes; @@ -12,6 +13,26 @@ use h3x::{ErrorCode, Role, Transport, TransportError}; use qrecovery::{recv::StopSending, send::CancelStream}; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +/// DHTTP permits long-running handlers without requiring application heartbeat traffic. +pub(crate) const CONNECTION_IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60); + +pub(crate) fn configure_quic( + mut endpoint: qconn::QuicEndpoint, +) -> crate::Result { + endpoint.set_alpn(vec![h3x::ALPN.to_vec()]); + // QUIC negotiates the shorter of the two advertised idle timeouts. + for role in [qbase::role::Role::Client, qbase::role::Role::Server] { + endpoint + .set_parameters( + role, + qbase::param::ParameterId::MaxIdleTimeout, + CONNECTION_IDLE_TIMEOUT, + ) + .map_err(io::Error::other)?; + } + Ok(endpoint) +} + #[derive(Clone)] pub(crate) struct QuicTransport { pub(crate) connection: Arc, diff --git a/dhttp/tests/dns_bootstrap.rs b/dhttp/tests/dns_bootstrap.rs index 1e4ddc0..02e262b 100644 --- a/dhttp/tests/dns_bootstrap.rs +++ b/dhttp/tests/dns_bootstrap.rs @@ -106,7 +106,7 @@ fn endpoint(root: &Path, directory: &str, name: &str) -> Endpoint { rustls_pemfile::private_key(&mut key.as_slice()) .unwrap() .unwrap(), - fs::read(ssl.join("ocsp.der")).unwrap(), + fs::read(ssl.join("stapling.ocsp")).unwrap(), ) .unwrap(); Endpoint::new(identity) diff --git a/dhttp/tests/listener_lifecycle.rs b/dhttp/tests/listener_lifecycle.rs index c580aae..ebff50f 100644 --- a/dhttp/tests/listener_lifecycle.rs +++ b/dhttp/tests/listener_lifecycle.rs @@ -51,7 +51,7 @@ async fn listener_registers_service_and_releases_it_on_exit() { ) .unwrap(); } - std::fs::write(ssl.join("ocsp.der"), b"test-ocsp").unwrap(); + std::fs::write(ssl.join("stapling.ocsp"), b"test-ocsp").unwrap(); let other_ssl = root.join("other").join("ssl"); std::fs::create_dir_all(&other_ssl).unwrap(); let other_cert = @@ -71,7 +71,7 @@ async fn listener_registers_service_and_releases_it_on_exit() { ) .unwrap(); } - std::fs::write(other_ssl.join("ocsp.der"), b"other-ocsp").unwrap(); + std::fs::write(other_ssl.join("stapling.ocsp"), b"other-ocsp").unwrap(); // This test binary has one test and sets the home before starting network tasks. unsafe { std::env::set_var("DHTTP_HOME", &root) }; @@ -151,6 +151,14 @@ async fn listener_registers_service_and_releases_it_on_exit() { .get(endpoint.name()) .unwrap(); let second = qconn::ServerRegistry::global().get(other.name()).unwrap(); + for listener in [&first, &second] { + assert_eq!( + listener + .server_parameters + .get::(qbase::param::ParameterId::MaxIdleTimeout), + Duration::from_secs(5 * 60), + ); + } // The new QUIC callback only delivers authenticated connections. Rejecting // an unsupported negotiated ALPN at the H3 boundary must still be local to // that connection and preserve the listener registration. diff --git a/dhttp/tests/quic_roundtrip.rs b/dhttp/tests/quic_roundtrip.rs index 91bd6f5..a93a9ff 100644 --- a/dhttp/tests/quic_roundtrip.rs +++ b/dhttp/tests/quic_roundtrip.rs @@ -107,7 +107,7 @@ async fn injected_resolver_drives_authenticated_http3_over_udp() { let handshake = request.extensions().get::().unwrap(); assert_eq!(handshake.alpn.as_deref(), Some(b"h3".as_slice())); assert_eq!(handshake.local.as_ref().unwrap().name(), "server.dhttp.net"); - if request.uri().path() == "/anonymous" { + if request.uri().path().starts_with("/anonymous") { assert!(handshake.remote.is_none()); } else { assert_eq!( @@ -115,7 +115,13 @@ async fn injected_resolver_drives_authenticated_http3_over_udp() { "client.dhttp.net" ); } + let slow = matches!(request.uri().path(), "/slow" | "/anonymous-slow"); let bytes = request.into_body().collect().await?.to_bytes(); + if slow { + // Exceed both old transport defaults (20s client / 30s server) + // with no response bytes or application heartbeat requests. + tokio::time::sleep(Duration::from_secs(35)).await; + } Ok::<_, dhttp::BoxError>(http::Response::new(Full::new(bytes))) }), ) @@ -254,6 +260,35 @@ async fn injected_resolver_drives_authenticated_http3_over_udp() { 2, "anonymous requests need a distinct reusable connection" ); + let (named, anonymous) = tokio::time::timeout(Duration::from_secs(45), async { + tokio::join!( + async { client.get("https://server~/slow".parse().unwrap()).await }, + async { + dhttp::Anonymous + .get("https://server~/anonymous-slow".parse().unwrap()) + .await + }, + ) + }) + .await + .expect("silent handlers must finish within the application deadline"); + for response in [named.unwrap(), anonymous.unwrap()] { + assert_eq!(response.status(), http::StatusCode::OK); + assert!( + response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .is_empty() + ); + } + assert_eq!( + resolutions.load(Ordering::SeqCst), + 2, + "silent responses must use the original connections" + ); let reverse = tokio::time::timeout( Duration::from_secs(3), server.get("https://client~/reverse".parse().unwrap()), diff --git a/dhttp/tests/support/credentials.rs b/dhttp/tests/support/credentials.rs index 18610ae..93c97bd 100644 --- a/dhttp/tests/support/credentials.rs +++ b/dhttp/tests/support/credentials.rs @@ -157,7 +157,7 @@ pub fn generate(root: &Path, names: &[(&str, &str)]) { "-cert", &cert, "-respout", - &format!("{directory}/ocsp.der"), + &format!("{directory}/stapling.ocsp"), "-ndays", "1", ], @@ -167,7 +167,7 @@ pub fn generate(root: &Path, names: &[(&str, &str)]) { &[ "ocsp", "-respin", - &format!("{directory}/ocsp.der"), + &format!("{directory}/stapling.ocsp"), "-issuer", "ca.crt", "-cert", diff --git a/home/Cargo.toml b/home/Cargo.toml index b6fe074..bebd06e 100644 --- a/home/Cargo.toml +++ b/home/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dhttp-home" description = "Local identity home and profile management for DHttp" -version = "0.6.0" +version = "0.6.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/home/src/identity/ssl.rs b/home/src/identity/ssl.rs index 8f2ff61..6b03087 100644 --- a/home/src/identity/ssl.rs +++ b/home/src/identity/ssl.rs @@ -25,7 +25,7 @@ pub use management::*; pub const SSL_DIR_NAME: &str = "ssl"; pub const CERT_FILE_NAME: &str = "fullchain.crt"; pub const KEY_FILE_NAME: &str = "privkey.pem"; -pub const OCSP_FILE_NAME: &str = "ocsp.der"; +pub const OCSP_FILE_NAME: &str = "stapling.ocsp"; #[derive(Snafu, Debug)] #[snafu(module)]