Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
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"
Expand All @@ -18,14 +18,14 @@
serde = { version = "1", features = ["derive"] }
futures = "0.3"
netwatcher = { version = "0.8", features = ["tokio"] }
matchit = "0.9"

Check warning on line 21 in Cargo.toml

View workflow job for this annotation

GitHub Actions / doc

unused workspace dependency `matchit`

Check warning on line 21 in Cargo.toml

View workflow job for this annotation

GitHub Actions / clippy

unused workspace dependency `matchit`

Check warning on line 21 in Cargo.toml

View workflow job for this annotation

GitHub Actions / doc

unused workspace dependency `matchit`

Check warning on line 21 in Cargo.toml

View workflow job for this annotation

GitHub Actions / clippy

unused workspace dependency `matchit`
rustls = { package = "qrustls", version = "0.23.45", default-features = false }
tokio = { version = "1" }
toml = "1"
tower-service = "0.3"
tracing = "0.1"
x509-parser = "0.18"
bon = "3"

Check warning on line 28 in Cargo.toml

View workflow job for this annotation

GitHub Actions / doc

unused workspace dependency `bon`

Check warning on line 28 in Cargo.toml

View workflow job for this annotation

GitHub Actions / clippy

unused workspace dependency `bon`

Check warning on line 28 in Cargo.toml

View workflow job for this annotation

GitHub Actions / doc

unused workspace dependency `bon`

Check warning on line 28 in Cargo.toml

View workflow job for this annotation

GitHub Actions / clippy

unused workspace dependency `bon`
http = "1"
http-body = "1"
http-body-util = "0.1"
Expand All @@ -39,7 +39,7 @@
derive_more = "2"
clap = { version = "4", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt"] }

Check warning on line 42 in Cargo.toml

View workflow job for this annotation

GitHub Actions / doc

unused workspace dependency `tracing-subscriber`

Check warning on line 42 in Cargo.toml

View workflow job for this annotation

GitHub Actions / clippy

unused workspace dependency `tracing-subscriber`

Check warning on line 42 in Cargo.toml

View workflow job for this annotation

GitHub Actions / doc

unused workspace dependency `tracing-subscriber`

Check warning on line 42 in Cargo.toml

View workflow job for this annotation

GitHub Actions / clippy

unused workspace dependency `tracing-subscriber`

# Release dependencies use compatible crates.io versions.
qbase = { version = "0.7.0" }
Expand All @@ -51,7 +51,7 @@
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" }
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.

Expand Down
56 changes: 24 additions & 32 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion api/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 发布仍待完成。

## 流式请求

Expand Down
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion api/tests/quic_roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
9 changes: 5 additions & 4 deletions dhttp/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ impl DhttpNetwork {
scopes: Scopes,
service: BoxService,
) -> Result<crate::ListenFuture> {
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<str> = Arc::from(endpoint.name());
{
let mut listeners = self.listeners.lock().unwrap();
Expand Down Expand Up @@ -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)?;
Expand Down
8 changes: 4 additions & 4 deletions dhttp/src/network/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
};
Expand Down
21 changes: 21 additions & 0 deletions dhttp/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,34 @@ use std::{
pin::Pin,
sync::Arc,
task::{Context, Poll},
time::Duration,
};

use bytes::Bytes;
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<qconn::QuicEndpoint> {
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<qconn::ArcConnection>,
Expand Down
2 changes: 1 addition & 1 deletion dhttp/tests/dns_bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 10 additions & 2 deletions dhttp/tests/listener_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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) };

Expand Down Expand Up @@ -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::<Duration>(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.
Expand Down
37 changes: 36 additions & 1 deletion dhttp/tests/quic_roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,21 @@ async fn injected_resolver_drives_authenticated_http3_over_udp() {
let handshake = request.extensions().get::<HandshakeSummary>().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!(
handshake.remote.as_ref().unwrap().name(),
"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)))
}),
)
Expand Down Expand Up @@ -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()),
Expand Down
4 changes: 2 additions & 2 deletions dhttp/tests/support/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion home/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading