Changelog
Notable changes per release. Versions follow semver: a major version means a breaking change — see Migrating to 18.0 for the next one. Releases and binaries: GitHub releases.
18.8.3 — 2026-10-29
Fixed
- Automatic HTTPS against Let's Encrypt: the ACME client closed its side of the connection before reading the newNonce reply, and Let's Encrypt's front end answers nothing to such a HEAD, so every order failed with "Replay-Nonce missing from newNonce response" (Pebble, which CI tests against, tolerated it). The request is no longer half-closed.
watch = truewith[acme]: the watcher started before the first certificate was ordered, saw the certificate and key being written and sentSIGHUPbefore the handler was installed, which ended the process. It now starts once ACME has finished, before the servers run.- Adding a name to
[acme] domainsnow gets a certificate for it: the stored certificate counted as current while it was valid, even when it didn't cover every configured name, so the new name kept the old certificate until renewal. A certificate missing a configured name is ordered again.
Website
- rws8.tech is a new site built in
site/(lit-material, prerendered with Lit SSR) and served by rws itself: a landing page, benchmarks charts, a config playground whose every output passesrws --check, and the docs for every minor release of the current major under/docs/<minor>/. Old doc URLs redirect to/docs/…. Starlight and the GitHub Pages deploy are gone; each release attachesrws8-site-<tag>.tar.gz, which the server installs.
Documentation
- Config file reference: the rewrite rule types are
header_set,header_remove,uri_set,uri_strip_prefix,uri_add_prefix,statusandbody_replace; the page listed names (set_header,set_status, ...) thatrws --checkrejects.
18.8.2 — 2026-10-28
Documentation
- Gateway API benchmark results (docs/deployment/kubernetes-gateway-api):
rws against Envoy Gateway, NGINX Gateway Fabric, Traefik, kgateway and
Istio over HTTP/1.1, HTTPS, HTTP/2, 1 MiB bodies and a filter-heavy route;
the harness (
bench/gateway/run.sh, workflowgateway-bench.yml) covers those scenarios. No code changes.
18.8.1 — 2026-10-28
Changed
- Gateway API router: requests are forwarded on the connection's task through the async upstream forwarder (plain and BackendTLSPolicy backends; pools of up to 1024 idle connections per endpoint) instead of holding a blocking-pool thread each. At 256 connections the blocking path fell to 15k req/s with 119 ms p99 and some failed requests in the gateway benchmark. Upgrades and h2c backends keep the blocking path.
- Helm chart: with
gatewayAPI.enabledthe pods get no CPU limit (theresources.limits.cpudefault of500mis left out; memory and requests apply as set). A Gateway API data plane serves every Gateway's traffic, and half a CPU throttled it into high tail latency in the gateway benchmark. Deployments withoutgatewayAPI.enabledare unchanged.
18.8.0 — 2026-10-28
Added
- Kubernetes Gateway API controller (#19,
gateway-apifeature; see docs/deployment/kubernetes-gateway-api): conformant with Gateway API v1.6.3 (standard channel) for the GATEWAY-HTTP, GATEWAY-GRPC, GATEWAY-TLS, GATEWAY-TCP and GATEWAY-UDP profiles with every standard-channel extended feature, in shared and provisioned (a Deployment per Gateway) modes. HTTPRoute (matching, the filters, timeouts, mirroring, CORS, h2c and WebSocket backends), GRPCRoute, TLSRoute (passthrough and terminate), TCPRoute, UDPRoute, ListenerSet, ReferenceGrant, BackendTLSPolicy (with SAN checks), client-certificate validation, static addresses; a Service and EndpointSlice per Gateway, Lease leader election, Helm values (gatewayAPI.*), and a CI job running the official suite in kind in both modes. The Linux release binaries and the container image include it (off unless enabled; it doubles the binary, so macOS and Windows builds don't).
Fixed
- HTTP/2 requests carry their
:authorityas theHostheader when the client sent nohostfield (RFC 9113 §8.3.1): applications, routers with.with_host, and the Gateway API router saw no host for HTTP/2 requests.
Changed
- The minimum supported Rust version is 1.99 (was 1.88): kube 4.2, under the
gateway-apifeature, and its dependencies need 1.89+, and the project now tracks the latest stable release.
18.7.0 — 2026-10-27
Added
-
OpenAPI request and response schemas (#22):
OpenApiConfig::operation(method, pattern, Operation)describes an operation's summary,operationId, tags, JSON request body (.body::<T>()), responses (.response::<T>(status, …),.status(…)), query parameters (.query::<T>()) and path parameter types, with schemas fromJsonSchema— the derivation MCP tools use, so serde renames are followed.examples/mcp_inventory.rsdescribes its REST API;ci/e2e/openapi.shvalidates the document with openapi-spec-validator. -
Tower interop, opt-in (#23,
towerfeature):tower::mount/TowerAppserve a Tower service (a tonic gRPC server, tower-http'sServeDir, an axumRouter) from an rws app, bodies streaming both ways with gRPC trailers;TowerLayeruses a Tower layer (tower-http) as rws middleware;TowerServiceserves an rws app from hyper or axum. Tower futures run on a dedicatedrws-towerruntime. rws's own request path is unchanged, and the default build gets no new dependency.examples/tower.rs(tonic-health + tower-http);ci/e2e/tower.shcalls it with grpcurl over h2c. -
{param}route syntax (#42):/users/{id}and/files/{*path}beside/users/:idand/files/*path— what axum 0.8 and OpenAPI use — inRouter,AppWithState,AsyncAppWithStateand WebSocket routes;{{and}}are literal braces. OpenAPI operations match routes in either spelling. -
Starters (#42):
starters/api(JSON API + OpenAPI 3.1) andstarters/mcp(MCP server), standalone projects with their ownAGENTS.md, copyable withcargo generate;ci/e2e/starters.shbuilds and tests them against the checkout on every push. -
An agent pack (#42):
agents/skills/rws/SKILL.md(Agent Skills: Claude Code, Codex, …) with a Cursor rule and Copilot instructions generated from it (ci/gen_agents.py), published atrws8.tech/agents/. -
llms.txt/llms-full.txton the site, at the root and per release under/<major.minor>/(they were only in the repository: the site's links to them were broken). Docs page: getting-started/ai-agents.
Changed
/openapi.jsonis OpenAPI 3.1.0 (was 3.0.3):JsonSchemaemits JSON Schema 2020-12, which 3.1 uses as is.OpenApiConfighas a new public field,operations; code that builds it with a struct literal must set it (Vec::new()), or useOpenApiConfig::new.examples/mcp_inventory.rsneeds theopenapifeature too.- A route pattern segment written
{name}is now a parameter; before 18.7.0 it matched the literal text{name}. Write{{name}}for literal braces.
18.6.0 — 2026-10-27
Added
- Config-file proxy parity (#21): an
[[upstream]]takesweights(smooth weighted round-robin; proportionalrandomandleast_connections),[upstream.sticky](aHttpOnlycookie naming the backend by a hash of its URL),[upstream.circuit_breaker](passive health checks: failing backends leave rotation forrecovery_secs;503+Retry-Afterwhen all are out) and[upstream.retry](idempotent requests with a buffered body go to another backend on no response or a listed status). All checked byrws --check. - Per-upstream metrics on
/metrics:rws_upstream_requests_total,rws_upstream_request_duration_seconds,rws_upstream_retries_total,rws_upstream_backend_up,rws_upstream_circuit_breaker_state. ci/e2e/proxy_policies.shruns these against real backends.http_server_request_duration_seconds(#25): the OpenTelemetryhttp.server.request.durationmetric, recorded by the server core for every request on HTTP/1.1, HTTP/2 and HTTP/3, labelled by method, status, scheme and protocol version.- Prometheus native histograms (#25):
/metricsanswers in the protobuf format when the scraper asks for it (Prometheus withscrape_native_histograms), with exponential buckets forhttp_server_request_duration_seconds.ci/e2e/native_histograms.shchecks it against Prometheus 3.15. - OTLP metrics export (#25):
otel::metrics::start/start_from_envpushhttp.server.request.durationto an OpenTelemetry collector over OTLP/HTTP (JSON, cumulative) everyOTEL_METRIC_EXPORT_INTERVAL, with a last export on shutdown. Therwsbinary exports metrics and traces (wrapping everything inOtelLayer) whenOTEL_EXPORTER_OTLP_ENDPOINTis set.OTEL_EXPORTER_OTLP_{METRICS,TRACES}_ENDPOINTand…_HEADERSare honoured.ci/e2e/otlp.shchecks it against otelcol-contrib 0.162. - Automatic client spans and W3C Baggage (#25): inside a request traced by
OtelLayer,http_client::Client/AsyncClient, the HTTP/1.1 proxies andDbPool/DbTransactionstatements get client spans, and outbound calls carrytraceparentandbaggage(otel::Baggage,current_baggage,set_baggage).otel::Contextcarries a span across threads (used forAsyncAppWithStatehandlers);otel::detached_spanis aSendspan for async code.ci/e2e/otlp.shfollows a proxied request into the collector. New fuzz targetotel_baggage. - TLS metrics (#25):
rws_tls_handshakes_total{tls_protocol_version,tls_cipher,tls_group},rws_tls_handshake_failures_totalandrws_tls_certificate_expiry_timestamp_seconds{name}(default certificate, virtual hosts, on-demand names).
Fixed
- HTTP/2 over cleartext (h2c) no longer sends
Strict-Transport-Security. - Trace and span IDs are random: they were a per-process counter (plus the start second), so two rws instances in one trace minted the same span IDs.
- OTLP span export no longer runs on the request thread when a batch fills
(a background thread sends it; partial batches go out after 5 s), honours
the endpoint's path and
https://, and escapes span names and attribute values in its JSON.
Changed
-
rustls is built with
prefer-post-quantum(its own default, which rws'sdefault-features = falsehad dropped): the server now picks the X25519MLKEM768 hybrid key exchange when a client offers it (current Chrome, Firefox, Safari, curl with OpenSSL 3.5), instead of plain X25519, and outbound connections (proxy, ACME, HTTP client) offer it first. -
proxy_config::UpstreamConfighas new public fields (weights,sticky,circuit_breaker,retry); code that builds it with a struct literal must set them (Vec::new()/None).
18.5.0 — 2026-10-27
Added
- MCP 2026-07-28 beside the session-based versions, on one endpoint (#15):
stateless requests (
_metaversion and capabilities,MCP-Protocol-Version/Mcp-Method/Mcp-Namechecked against the body:-32602,-32020,-32022, all400),server/discover,resultTypeandserverInfoon every result,ttlMs/cacheScopeon cacheable ones (.cache_hints),subscriptions/listen(acknowledged, filtered, tagged), request-scoped progress and log messages (ctx.log, only with alogLevel), and multi round-trip requests:McpContent::input_required(InputRequired::new() .elicit(..).sample(..).list_roots(..).state(..)),PromptReplywith.prompt_with_context,ctx.input_response/elicitation_content/request_state(HMAC-signed, bound to method, name, caller and 10 min;.request_state_keyfor replicas) /client_supports; a request for an undeclared capability is-32021. Removed methods are404 -32601there. Official conformance suite 0.2.0-alpha.12 in CI: every scenario 2026-07-28 requires passes, and 2025-11-25 stays at its baseline (#16). - The MCP Tasks extension (
io.modelcontextprotocol/tasks, #15): a tool returnsctx.start_task(|task| ...)— a task handle for clients that declared the extension (tasks/get,tasks/update,tasks/cancel,notifications/tasksonsubscriptions/listen), the work run inline for any other.TaskContext::inputparks the task asinput_requireduntiltasks/updateanswers;TaskErrormaps to completed-with-error, failed or cancelled;.task_required(name)refuses non-declaring clients (-32021);.task_ttl,.max_running_tasks(256: each running task holds a thread). Tasks belong to their caller. Mcp-Param-*headers (MCP 2026-07-28, SEP-2243): tool parameters markedx-mcp-headermust be mirrored in matching headers ontools/call(400 -32020otherwise); base64 header values must be canonical. The conformance suite's 2026-07-28 run now passes every check, 196, including the unscored ones.resources/templates/list: resources registered with a URI template are listed there;resources/listholds only concrete resources (it listed templates as resources before, which the suite flags).crate::core::sha256: SHA-256 and HMAC-SHA256, no dependency.- Streaming Server-Sent Events (#24):
Sse::stream()returns the response and anSseSender; events reach the client as they are sent, over HTTP/1.1, HTTP/2 and HTTP/3.sendwaits for room (bounded memory),try_senddrops,send_asyncfor async code;Err(SseClosed)once the client is gone, found by a keep-alive comment every 15 s.SseStreamsets the buffer and interval. The MCP server'sGET /mcpstream now uses it. - WebSocket routes (#24):
AppWithState::websocket/Router::websocket, orWebSocket::upgrade(request, handler)in any handler, hand the upgraded connection to the handler on a thread of its own.WebSocketConnection::recvreturns whole messages (fragments joined, pings answered, the closing handshake done, protocol errors closed with 1002/1007/1009);WebSocketSendersends from other threads. HTTP/1.1, plain and TLS, async andhttp1builds.examples/realtime.rs; e2e with Node's WebSocket and fetch (ci/e2e/realtime.sh).
Fixed
SseEventencoding: a lone\rindata, or a line break inidor the event type, could add SSE fields of its own;datais now split at every line end andid/event type keep one line.
18.4.0 — 2026-10-27
Fixed
- Per-core runtimes: startup returns only once every runtime's socket is bound. Between the setup listener closing and those sockets opening, a connection could be refused or reset; the gap is now microseconds, and "Started N per-core runtimes" is printed once the port is served again.
- A WebSocket Close frame whose reason isn't UTF-8 now fails the frame, as
RFC 6455 §7.1.6 requires (text frames already did). It was decoded
lossily, which could make the reason up to three times the frame payload
limit (found by the
websocket_framefuzz target).
Added
- TLS-ALPN-01 (RFC 8737, #17):
[acme] challenge = "tls-alpn-01"gets and renews certificates on the HTTPS port alone — no port 80. At startup a temporary acceptor answers on the HTTPS socket; afterwards the TLS listener answersacme-tls/1handshakes with the challenge certificate (and only those). Tested against Pebble, first issuance and renewal. - DNS-01 and wildcard certificates (#17):
[acme] challenge = "dns-01"publishes_acme-challengeTXT records throughdns_provider = "rfc2136"(a DNS UPDATE to the zone's primary server, signed with TSIG HMAC-SHA256/512 — BIND, Knot, PowerDNS, …) or"exec"(a program, for any DNS API), waits until the record is visible, and removes it afterwards;*.example.comindomainsgets a wildcard certificate. Refusals are explained (REFUSED, NOTAUTH, TSIG BADSIG/BADKEY/BADTIME). Tested against Pebble and BIND (ci/e2e/acme_dns01.sh); the DNS reply parser — now one module,dns, shared with SRV service discovery — is fuzzed (fuzz/fuzz_targets/dns_message.rs). - DNS-01 through the Cloudflare and Route 53 APIs (#17):
dns_provider = "cloudflare"(an API token,dns_api_token) or"route53"(requests signed with AWS Signature V4 fromAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN); the zone bydns_zone_idor looked up;dns_api_urlfor a proxy or test double. Tested against stand-in APIs writing into BIND, signatures checked independently (ci/e2e/acme_dns_api.sh). challenge = "dns-persist-01"(#17, draft-ietf-acme-dns-persist): one TXT record at_validation-persist.<domain>you publish once — no port, no DNS credentials on the server, wildcards too. rws prints the exact record, checks it's visible before asking the CA, and never changes it. Tested against Pebble.- On-demand TLS (#18): with
[acme] on_demand = trueand anon_demand_askendpoint, rws orders a certificate at the first handshake for a name the endpoint allows (GET <ask>?domain=<name>, 2xx) — for customers' own domains. Refused names get the default certificate; one order per name, at most four at once; certificates kept under.rws/acme/on-demand/, loaded at startup and renewed.rws --checkrequires the ask endpoint and refuses DNS-01 with it. Tested against Pebble (ci/e2e/acme_on_demand.sh). - Replicas sharing certificates through Redis (#18): with
[acme] redis, one replica orders each certificate (a lock in Redis) while the others wait and install it, renewals reach every replica, and any replica answers HTTP-01 and TLS-ALPN-01 challenges another presented. Redis being down isn't fatal. Tested with two replicas against Pebble (ci/e2e/acme_shared.sh): one order per certificate. [acme] challenge(RWS_CONFIG_ACME_CHALLENGE:http-01,tls-alpn-01,dns-01) and the DNS-01 settings (dns_provider,dns_server,dns_zone,dns_tsig_key,dns_tsig_secret,dns_tsig_algorithm,dns_exec,dns_propagation_secs);rws --checkvalidates them and accepts a wildcard domain withdns-01.
Changed
acme::AcmeConfighas new public fields (challenge,dns): code that builds it as a struct literal adds them (AcmeConfig::from_envis unaffected).- Startup output no longer prints the values of settings whose names
contain
SECRET,PASSWORDorTOKEN("(hidden)").
18.3.0 — 2026-10-27
Added
- gRPC and HTTP/2 upstreams at parity with HTTP/1.1 (#13):
H2ReverseProxy,GrpcProxyand the config file'sgrpcaction keep one pooled, multiplexed connection per backend (a new handshake per request before); stream response bodies as they arrive and request bodies as they arrive, so server-streaming, client-streaming and bidirectional gRPC calls work; and pass the backend's trailers (grpc-status,grpc-message) to HTTP/2 and HTTP/3 clients, withte: trailersforwarded. A backend failing mid-call resets the client's stream. Response::trailersandresponse::Trailers: trailer fields sent after a body over HTTP/2 and HTTP/3 (HTTP/1.1 drops them).- Upstream client certificates (mutual TLS):
tls_client_cert(cert, key)onReverseProxy,H2ReverseProxyandGrpcProxy,[[upstream]] tls_client_cert_file/tls_client_key_file,UpstreamTls::new(ca, client).tls_ca_filenow also applies to HTTP/2 and gRPC upstreams (H2ReverseProxy::tls_ca_file,GrpcProxy::tls_ca_file). Middleware::answers_streamed_body: a layer that answers a request itself can take its body as a stream, whatever the inner application says.- gRPC-web bridge (#14):
GrpcWebLayertranslatesapplication/grpc-weband-textrequests to gRPC and the responses back — the status as the body's last frame — so browsers can call gRPC services through rws; the config file'sgrpcaction does it on its own. Checked from headless Chromium over HTTP/1.1 and HTTP/2 (ci/e2e/grpc_web.sh). - Inbound PROXY protocol v1 and v2 (#20):
proxy_protocol = true(RWS_CONFIG_PROXY_PROTOCOL) takes the client address from the header an L4 load balancer (HAProxy, AWS NLB, GCP) prepends to each connection, on the plain and TLS listeners; connections without one are closed. Tested behind HAProxy (ci/e2e/proxy_protocol.sh); parser fuzzed (fuzz/fuzz_targets/proxy_protocol.rs). - Cleartext HTTP/2 (h2c, prior knowledge) on the plain listener (#20): a
connection opening with the HTTP/2 preface is served as HTTP/2 — gRPC
without TLS,
grpcurl -plaintext,curl --http2-prior-knowledge. Anything else is HTTP/1.1 as before.
Changed
Responseandproxy_config::UpstreamConfighave new public fields (trailers;tls_client_cert_file,tls_client_key_file): code that builds them as struct literals adds them (trailers: None) — the constructors (Response::new,text,json,get_response) are unaffected. As withstream_pipein 17.110.0.- The config file's
grpcaction forwards over HTTP/2 (it used HTTP/1.1, so gRPC didn't work through it), andgrpc://,grpcs://andh2s://backends passrws --check. - HTTP/2 requests keep
te: trailersfor the application (it was validated, then dropped).
Fixed
- An HTTP/1.1 response with no body (a handler returning
Response::new(), a gRPC-web trailers-only error) had noContent-Length, so a keep-alive client waited for a body until the connection closed. It now saysContent-Length: 0(not for 1xx, 204 and 304, which never have content, nor for streamed bodies).
18.2.0 — 2026-10-26
Security
- zstd 0.14.3, zstd-safe 8.2.0, zstd-sys 2.1.2 (RUSTSEC-2026-0391, -0398,
-0403, -0410, published 2026-10-16; taken although newer than the two-week
rule, being security fixes). rws calls only
zstd::bulk::compresson its own response bodies — a fresh context per call, no decompression, no WebAssembly build with zstd — so 18.1.0 isn't known to be exposed; the crate now requires zstd ≥ 0.14.3.
Added
- Runnable examples (#11):
hello,rest_api,static_site,reverse_proxy,sse_tickeranduploadjoin the MCP examples inexamples/, each with a testcargo testruns; listed on the new Examples page. CI now tests with theserdeandmacrosfeatures too, sorest_apiandmcp_inventoryrun on every push. AGENTS.md: a guide for coding agents building an app on the crate.- Helm chart published with each release (#26):
helm install my-rws oci://ghcr.io/bohdaq/charts/rws --version <version>(chart version = rws version, imageghcr.io/bohdaq/rws:<version>by default). New optionalIngressand prometheus-operatorServiceMonitor;helm/prometheus-adapter-values.yamlserves the HPA's request-rate metric. CI lints the chart, checks it with kubeconform and installs it into kind with TLS on and off. - deb and rpm packages (#27) for x86_64 and arm64 on each release:
/usr/bin/rws, the hardenedrws.service(now the one on the systemd page; it serves/var/www/rwsand reads/etc/rws/rws.config.toml), started on port 7878 on install, and signed apt and dnf repositories athttps://rws8.tech/aptand/rpm(newest release; the rpm files are signed too). CI builds the repositories with a throwaway key and installs from them and from the files on systemd hosts. The release workflow now pushes the rws8.techdocsbranch as one orphan commit per release (the branch is only a deploy target; packages would bloat its history). - Windows service (#27):
rws --windows-service=<directory>runs under the Service Control Manager; Stop and Shutdown end it gracefully (server::request_shutdown, which library users can call too). New page Windows. Adds thewindows-servicedependency on Windows only. - winget manifests (
packaging/winget/generate.sh) and a nixpkgs expression (packaging/nix/package.nix), checked in CI and submitted upstream per release (#27).
Fixed
- Helm chart with
tls.enabled(its default): liveness/readiness probes and the Prometheus scrape annotations used plain HTTP against the TLS port, so pods never became ready; they now use HTTPS (the Kubernetes docs' YAML had the same mistake). The HPA's custom metric was anExternalrws_route_requests_totalnothing served; it is now thePodsmetricrws_requests_per_secondfrom the Prometheus Adapter rule. The chart's scrape annotations moved frompodAnnotationstometrics.podAnnotations, and the Service port is namedhttp(port 80) when TLS is off. - MCP
ctx.sample()/ctx.list_roots()called right afterinitialize, before the client had opened itsGET /mcpstream, sent the request into nothing and timed out; they now wait for the stream within the same timeout. SDK clients open that stream in the background, so this made the conformance suite'stools-call-samplingscenario fail about one run in three. - Release workflow: the docs-site job builds on Node 24 (Astro needs ≥ 22.23.4; the runner's Node 22 was older, so 18.1.0's site update failed).
18.1.0 — 2026-10-26
Added
- brotli and zstd compression (#7): responses are compressed in the coding
the client prefers by
Accept-Encodingq-values (br,zstdorgzip), at the fast levels peers use per response (brotli 4, zstd 3); static files are compressed once per version per coding (brotli 6, zstd 6) and cached, and precompressedapp.js.br/.zst/.gzsiblings are served when at least as new as the file.compression::apply_compression,negotiate,encode,Encoding,Level; Cargo featuresbrotliandzstd(default). New benchmark scenariostext-brandtext-zstd. - Reload on file change (#10):
watch = true(RWS_CONFIG_WATCH) reloads whenrws.config.tomlor a configured certificate or key changes, with the same validation asSIGHUP; covered byci/e2e/config_reload.sh. - Access log to a file (#9):
access_log(RWS_CONFIG_ACCESS_LOG) writes access-log lines to a file instead of stdout;access_log_max_sizerotates it by size (access.log→access.log.1…, keepingaccess_log_keep), andSIGUSR1reopens it for logrotate (only when a file is configured; otherwiseSIGUSR1keeps its default action). A hardened systemd unit is documented on the new systemd page. - Fuzz targets for WebSocket frames, DNS SRV responses, Redis replies and
Cookieheaders, and a request-smuggling seed corpus (CL+TE, conflicting or listedContent-Length, obfuscated or foldedTransfer-Encoding, bare-LF and oversized chunks) whose handling a test pins (#8).
Fixed
- WebSocket frames claiming a huge payload (#8).
WebSocket::read_frameallocated whatever length a client claimed (up to 2^64 − 1 bytes) before reading it, so one 14-byte frame could abort the process. Payloads are now read as they arrive and capped atWebSocket::MAX_FRAME_PAYLOAD(16 MiB);WebSocket::read_frame_limitedtakes another limit. - Redis replies (#8). The RESP reader allocated a bulk string at the
length the server claimed; bulk strings are now read as they arrive, capped
at 512 MiB (Redis's own maximum), reply lines at 64 KiB, and a connection
closed mid-reply is an error instead of reading as
+OK. - DNS SRV discovery (#8). A response whose SRV record ended inside its RDATA panicked while reading priority, weight and port; it is now an error.
- A compressed response kept the uncompressed body's strong
ETag, so a cache could not tell the representations apart; it now carries the coding ("x"→"x-gzip"), static files included. - An
Accept-Encodingparameter starting with a multi-byte character (gzip;aé) panicked while looking forq=, ending that connection (found by the newaccept_encodingfuzz target; the same code was in 17.x and 18.0.0).
18.0.0 — 2026-10-26
Breaking
- The
rwsbinary runs one runtime per core (RWS_CONFIG_RUNTIME=per-core, Unix): one single-threaded runtime per core on its ownSO_REUSEPORTsocket, as nginx runs workers. On the fastest benchmark runners that is where rws trailed nginx on requests per second; on slower machines it costs more CPU per small request.runtime = "shared"/RWS_CONFIG_RUNTIME=sharedrestores 17.x behaviour; library applications staysharedunless they opt in. Headerfields areCow<'static, str>instead ofString. Build withHeader::new(name, value)or.into(); read with&*h.value/h.value.as_ref().- Default response headers: client hints (
Accept-CH,Critical-CH, the hints inVary) are opt-in withRWS_CONFIG_CLIENT_HINTS=true;VaryisOriginotherwise. Static files sendCache-Control: no-cache(RWS_CONFIG_STATIC_CACHE_CONTROL) instead ofno-store, plus a standardLast-Modified; every response has a standardDate.X-Frame-Options,Referrer-Policy,Permissions-Policyand the default CSP are opt-in withRWS_CONFIG_SECURITY_HEADERS=true(a CSP set withRWS_CONFIG_CSPis always sent), andDate-Unix-Epoch-Nanos/Last-Modified-Unix-Epoch-NanoswithRWS_CONFIG_NANOS_HEADERS=true. Request.bodyis arequest::Body(#31), not aVec<u8>: the bytes as before (it derefs to&[u8]), or a stream of a body still arriving. Build with.into()/Default::default();.to_vec()where aVec<u8>is wanted. See the migration page.- ACME renewal follows the certificate and the CA (#1). Certificates are renewed at two-thirds of their lifetime, or earlier when the CA's ACME Renewal Information (RFC 9773) suggests it, instead of 30 days before expiry — which would renew a 6-day certificate at once and leave a 45-day one (Let's Encrypt's default from 2028) a third of its life. Renewal orders name the certificate they replace (
replaces).RWS_CONFIG_ACME_PROFILEorders a certificate profile (classic,tlsserver,shortlived).RWS_CONFIG_ACME_RENEW_BEFORE_DAYShas no default now; set it to keep a fixed margin.AcmeConfig::renew_before_daysis anOption<i64>andAcmeConfighas aprofilefield. The renewal loop sendsSIGHUPonly after a renewal (it did after every check). - MCP sessions are bounded (#4): idle for an hour (
McpServer::session_ttl) or past 10 000 (max_sessions, least recently used first) a session is evicted with its subscriptions and SSE streams;DELETE /mcpends one; at most 1000 SSE streams (max_sse_clients, then503). A request carrying anMcp-Session-Idthe server doesn't know now gets404— the transport's signal to initialize again — instead of running with an empty context. - CORS allow-all is off by default (#59).
RWS_CONFIG_CORS_ALLOW_ALLdefaulted totrue, which echoed anyOriginwithAccess-Control-Allow-Credentials: true— any site could read a logged-in user's responses. With no CORS configurationrwsnow adds no CORS headers; list origins inRWS_CONFIG_CORS_ALLOW_ORIGINS, or setRWS_CONFIG_CORS_ALLOW_ALL=truefor the old behaviour. - HTTP/2 server: rws serves HTTP/2 with its own implementation
(
h2_server) instead of theh2crate; the hiddenh2_handlermodule is gone. Same conformance (h2spec 146/146), limits and behaviour; theh2crate stays as the proxies' HTTP/2 client. - RSA keys:
auth::verify_jwt_rs256andSamlIdpMetadata.signing_keytakersa_key::RsaPublicKey(wasrsa::RsaPublicKey); RSA keys under 2048 bits are rejected. Thersacrate (RUSTSEC-2023-0071) is no longer a dependency of the auth/SSO features.
Added
- MCP conformance: the official suite (
@modelcontextprotocol/conformance0.1.16, 2025-11-25 scenarios) runs on every push againstexamples/mcp_conformance.rs(ci/e2e/mcp_conformance.sh): 28 of 31 pass, the three elicitation scenarios are known failures (ci/mcp-conformance-baseline.yml). For it,McpContent::audio,McpContent::blocks(several items in one tool result) andMcpContent::blob(binary resources, sent asblob). Atools/callwith aprogressTokenfrom a client that acceptstext/event-streamis answered as an event stream — progress notifications, then the result — instead of sending progress to the separateGET /mcpstream, which a client may not have opened yet; progress without such a stream now goes only to the caller's session (it went to every connected session). - MCP gateway (#62):
mcp::gateway::McpGatewayputs one endpoint in front of upstream MCP servers — tools as<prefix>__<tool>,allow(identity, tools)rules (deny by default), upstream credentials set perUpstreamand never the caller's; JSON and event-stream replies, paginatedtools/list, session re-initialization; upstream failures areisErrorresults.McpServer::require_bearer_tokens(a token per named caller, seen as{"sub":…}inauth_claims) andMcpServer::tool_filter(per-request tool visibility) work on any MCP server.examples/mcp_gateway.rs; docs page MCP gateway. - Typed MCP tools (#61):
McpServer::typed_tool(name, description, |ctx, args: In| -> Result<Out, String>)(serde) derivesinputSchema/outputSchemafrom the types, deserializes the arguments and returnsstructuredContent.#[derive(JsonSchema)](macros) and theschema::JsonSchematrait produce the schemas, following#[serde]renames; attributes that would make them disagree are compile errors.examples/mcp_inventory.rsexposes an app's domain functions as REST routes and MCP tools with OAuth and live resource updates; docs page Build agent tools. rws-macros must be published (with the derive) before rust-web-server 18.0.0. If-Modified-Sinceanswers304for static files (when there's noIf-None-Match).[[upstream]] tls_ca_file/ReverseProxy::tls_ca_file: trust a private CA forhttps://upstreams.- Inline tables in
rws.config.tomlsettings (rate_limit = { max_requests = 5 }). RWS_CONFIG_OPEN_FILE_CACHE_STRICT=false: reuse a cached file's metadata for the cache window instead of checking it per request.mimallocfeature: therwsbinary allocates with mimalloc v3; release binaries and images are built with it — 4–6% less CPU per request, at a larger memory footprint (about 34 MiB under load instead of 9; up to ~120 MiB serving large files over TLS). Build without the feature for the smaller footprint.RWS_CONFIG_RUNTIME=per-core: one single-threaded runtime per core, each on its ownSO_REUSEPORTlistener (Unix) — more throughput on fast CPUs, more CPU per request on slow ones; the default staysshared.- Supply chain (#5): release binaries are built with
cargo auditable(cargo audit bin rwsworks on them), releases carry a CycloneDX SBOM, archives and images get GitHub build-provenance attestations, and images are signed keyless with cosign. CI runscargo deny check(advisories, permissive licenses only, crates.io sources only;deny.toml). - Streamed request bodies (#31): an application that answers
truefromApplication::streams_body(orAppWithState::stream_body(method, pattern)) getsrequest.bodyas a stream it reads while the upload arrives — a 512 MiB upload stays under 64 MiB of memory. HTTP/1.1 (plain and TLS) and HTTP/2 (the stream window is credited back as the handler reads); HTTP/3 and the HTTP/1.1-only build still read the body first.Middleware::accepts_streamed_bodylets a header-only layer pass it through.Storage::put_stream(key, reader, content_type)stores a stream: S3 as a multipart upload in 8 MiB parts (aborted on failure), local disk via a temporary file renamed into place — a 2 GiB HTTP upload streamed to an S3-compatible server grew peak memory by under 96 MiB. - MCP (#3): structured tool results (
McpContent::structured,McpServer::tool_output_schema→outputSchema/structuredContent), tool and server titles (tool_title,title) and icons (McpIcon,tool_icon,icon).tools/callarguments are checked against the input schema (an object,requiredproperties, propertytypes); a mismatch is anisErrortool result rather than reaching the handler. Withrequire_oauth, the server publishes RFC 9728 protected-resource metadata at/.well-known/oauth-protected-resourceand its401carriesWWW-Authenticate: Bearer resource_metadata=…; the/.well-known/oauth-authorization-serverlook-alike document is gone. The server speaks protocol versions 2024-11-05 through 2025-11-25 (it advertised 2024-11-05 only and echoed any older string): an unsupported requested version is answered with 2025-11-25, an unsupportedMCP-Protocol-Versionheader gets400, and JSON-RPC batches are refused for sessions on 2025-06-18 or later.
Fixed
- MCP (#60):
extract_argreturns numbers and booleans (it read only strings), so the binary'sset_maintenance {"enabled": true}andfeature_setdid nothing andrecent_requests/recent_errorsignoredn; thereload_configtool now reloads (it set a flag only the HTTP/1.1-only server reads) via the newconfig_reload::request_reload(). The built-in tools carry read-only/destructive hints (McpServer::tool_annotations), and the new Operate rws from an AI client page has the setup and playbooks. - Docs (#12): MCP sampling, roots and logging are marked deprecated by the 2026-07-28 MCP specification (they keep working); the MCP SSE stream is documented — and now tested — over HTTP/2 and HTTP/3, not only HTTP/1.1.
- A request head must arrive within 30 s of its first byte, else
408— a slow-drip client could hold a connection for weeks. H2ReverseProxy::read_timeout_mswas ignored; a silent HTTP/2 backend hung the request.- The reverse proxy sent the client's
Content-Lengthupstream next to its own (two headers — strict backends reject that, and it's a desync risk). It now sends one, drops headers named inConnection,TrailerandProxy-Connection, and appends the client address to an incomingX-Forwarded-Forinstead of sending a second one. CacheLayerno longer storesno-cacheresponses, responses toRangerequests, or responses that vary on a header outside its key.- A JWKS key that can't be used is skipped instead of failing verification.
- HTTP/2 (#6): resets the server sends because the client broke a stream's rules (a zero or overflowing
WINDOW_UPDATE, a badPRIORITY, frames on a closed stream) count toward the rapid-reset budget, closing the "MadeYouReset" hole (CVE-2025-8671) in rws's own HTTP/2 server; and a connection with nothing running that sends nothing for 30 s — a silent client, a half-sent header block or request body, a client that never opens the flow-control window — is closed withGOAWAY(NO_ERROR). It used to stay open forever. If-Modified-Since(new in 18.0) accepts the obsolete RFC 850 and asctime date forms as well as the IMF-fixdate (RFC 9110 §5.6.7); a client sending them always got the whole file.If-Rangewith a date (on top of 17.113.2's entity-tag handling): theRangeapplies when the date equals the file'sLast-Modifiedand that is at least a second old (a strong validator), else the whole file is sent.
Performance
On the benchmark runner, 1 KiB static files went from 49k to 126k requests/s
and the 1 KiB reverse proxy from 28k to 49k: open files, small
file contents and gzip output are cached per file version; one write per
response (writev for large bodies, sendfile with the head in the same
segment); header lists, dates and numbers built without allocation; request
and upstream-response heads parsed in one pass; HTTPS upstreams forwarded
without blocking a thread; access log buffered per thread (flushed every
100 ms). Files over TLS are read on the connection's task (13% less CPU and
about half the memory for large downloads), HTTP/2 is served by rws's own implementation (20–37% less CPU per request
than with the h2 crate), and release builds
allocate with mimalloc (4–6% less CPU, more memory). Details:
Benchmarks.
17.113.2 — HTTP conformance
Upgrading
Some fixes refuse or change what 17.113.1 accepted:
- An HTTP/1.1 request without
Host, with twoHostheaders, with a lower-case method (get), with a folded header line or with whitespace before a header's colon now gets400(#57). - An HTTP/1.0 connection now closes after each response unless the client sends
Connection: keep-alive(RFC 9112 §9.3; #57). - CORS origins match whole: an allow-list entry that relied on matching part of an origin (
example.comforhttps://app.example.com) now matches nothing — list each origin (#58). - Request paths are percent-decoded: a file whose name contains a literal
%is requested as%25(#55).
Fixed
- gzip no longer rewrites a partial body (#52). A
Rangerequest withAccept-Encoding: gzipgot the requested slice gzipped andContent-Rangerewritten to the compressed size (bytes=0-1200of a 3,700-byte file →Content-Range: bytes 0-37/37), which no client can reassemble. A206is now sent uncompressed (RFC 9110 §14.4). - multipart/byteranges framing (#53). A multi-range response had neither
Content-LengthnorTransfer-Encoding, so a keep-alive client couldn't tell where it ended and waited; the closing delimiter lacked its trailing--; and the boundary was the fixed stringString_separator, which broke the framing of any part that contained it. The response now carries aContent-Length, ends with--boundary--, and uses a boundary (Range::multipart_boundary) that occurs in none of the parts (RFC 9112 §6.3, RFC 2046 §5.1.1). The legacyResponse::_parse_responsereads the boundary fromContent-Typeand no longer loops forever on a body without a closing delimiter. - Bodiless responses carry no body or Content-Length (#54).
1xx,204and304responses must have no content (RFC 9110 §6.4.1, §15.3.5):OPTIONSon a static file answered204withContent-Lengthand, over HTTP/2, aDATAframe. Every writer now drops the body (buffered, file or pipe) andContent-Length/Transfer-Encodingfor those statuses (Response::drop_content_for_status). And a200toOPTIONSnow sends its body: it used to announce it withContent-Lengthand then drop it, so the client waited for bytes that never came. - The static request path is percent-decoded (#55). Files were looked up by the raw request target, so
GET /my%20file.txtwas a404formy file.txt, and the directory listing's (correctly encoded) links to such names were dead. The path is now decoded once, before the web-root and hidden-file checks of #43 (so/%2e%2e/is refused like/../);%2520stays a literal%20. ARangerequest for a name containing a space, quote,&,|or;no longer gets416(the file reader refused such paths), and the general static path finds escaped names'ETag. - If-None-Match is a list, compared weakly (#56). Only a header equal to the file's
ETaggave304: a client sending several tags ("a", "b") or the weak form (W/"…") always got the whole file again (RFC 9110 §13.1.2). - If-Range is honored (#56). It was ignored: a client resuming a download with
If-Rangegot206with bytes of the file as it is now, even after the file changed. ARangenow applies only whileIf-Rangestrongly matches the currentETag; otherwise the whole file is sent with200(RFC 9110 §13.1.5). - Range: one satisfiable range is enough; 416 names the length (#56).
bytes=0-9, 99999-on a small file was a416because of the second range; unsatisfiable ranges are now skipped and416is sent only when none can be served — withContent-Range: bytes */<length>, which it lacked (RFC 9110 §14.2, §15.5.17). - 405 with Allow for files (#56).
POST/PUT/DELETEon an existing static file got404, which says the resource doesn't exist; it now gets405 Method Not AllowedwithAllow: GET, HEAD, OPTIONS(RFC 9110 §15.5.6). - Strict HTTP/1.1 request heads (#57). Requests without
Hostor with two, a lower-case method,HTTP/0.9/HTTP/2.0in the request line, folded header lines, whitespace before a header's colon, and header names that aren't tokens are answered400— each is a way forrwsand a proxy in front of it to read the same bytes differently (RFC 9112 §2.2–§3.2, §5.1).Connectionis evaluated token by token:Connection: close, TEused to keep the connection open; an HTTP/1.0 connection now stays open only withConnection: keep-alive.Request::parseitself stays lenient for library callers; the checks are in the connection loops (server::framing::validate_head). - CORS: whole-origin matching; exposed headers on the response (#58). An origin was allowed when it was a substring of the configured list, so with
https://app.example.comlisted,https://app.example.co(andhttps://pp.example.com) got CORS headers — with credentials when those are on. Origins are now compared whole (Cors::origin_is_allowed).Access-Control-Expose-Headerswas sent only on the preflight, which browsers ignore; it is now on the actual response.
17.113.1 — security patch
Fixes from the 2026-10-25 security review. Upgrade from 17.113.0.
Security
- Proxy: interim responses (#48). A
1xxfrom an upstream (100 Continue,103 Early Hints) was taken as the final response; the real response stayed in the pooled connection and was delivered to the next request on it — possibly another client's. Both forwarders now read past interim responses (at most 16;101 Switching Protocolsis still final). - Static files stay inside the web root (#43). A request path with
..segments (/../../etc/passwd) was resolved against the working directory and could read any file the process could; such paths now get404, and a symlink inside the root that points outside it is not followed. Dot-files and dot-directories (.git/,.env) are no longer served; setRWS_CONFIG_SERVE_DOTFILES=trueto serve them again (.well-known/is always served). - Byte-range limits (#46). Each range of a multi-range request was read into memory separately:
Range: bytes=0-,0-,0-,…(thousands of ranges in a 64 KiB head) made one request hold thousands of copies of a file. Overlapping ranges are now merged, and more than 16 ranges are answered with the whole file as one range (RFC 9110 §14.2). - No CR/LF in header lines (#47). The HTTP/1.1 writers copied header names, values and the reason phrase verbatim, so a value carrying user input (a redirect target, an upstream header) could inject headers or a second response; CR and LF now become spaces. The mailer does the same for
Subject,To,Cc,Reply-ToandFrom, and refuses an address containing CR, LF,<or>(it went intoMAIL FROM/RCPT TOas is). HTTP/2 and HTTP/3 already refused such values. - CacheLayer: never cache private responses (#49). The cache is keyed by URL, so a response built from one user's
CookieorAuthorizationwas served to the next visitor, aSet-Cookieresponse was replayed to everyone, aRangeresponse was served for the whole resource, and a streamed response was replayed with an empty body. Requests withAuthorization,CookieorRangenow skip the cache, and responses withSet-Cookieor a streamed body aren't stored. Therwsbinary wraps every response inCacheLayer. - Proxy routes match a normalized path (#50). Routes matched the raw request path, so
/%61dmin,//adminor/x/../adminskipped a route protecting/admin/*(auth, IP filter, rate limit) and reached the backend through a catch-all route, which then saw/admin. Paths are now normalized before matching and sent upstream normalized (unreserved escapes decoded, a stray%encoded, repeated slashes collapsed, dot segments resolved), andhostmatches ignoring case and port. /mcpneeds a token (#44). Therwsbinary served its MCP tools — config reload, blocklist, maintenance mode, server introspection — to anyone who could reach the port unlessMCP_TOKENwas set. Now they're mounted only with a non-emptyMCP_TOKEN, and/mcpsits behind the access log, IP blocklist and global rate limit. InMcpServer: a request with anOriginheader not listed in the new.allowed_origins(...)gets403(browser cross-site requests and DNS rebinding;MCP_ALLOWED_ORIGINSin the binary), aPOSTthat isn'tapplication/jsongets415, an empty bearer token refuses every request, and tokens are compared in constant time.- Private keys out of the web root (#45). With no paths configured, ACME stored
cert.pem,key.pemandacme_account.keyin the working directory — the web root — so both private keys could be downloaded. They now default to.rws/acme/(keys0600, directory0700, never served); on upgrade, files at the old default paths are moved there. The static server no longer serves*.pem,*.keyorrws.config.tomlanywhere. If you ran ACME without explicit paths, rotate the certificate key and the ACME account key. The Docker image already kept them in/data.
17.113.0 — 2026-10-24
- Proxy: pooled keep-alive upstream connections on every HTTP/1.1 path, pooled HTTPS backends, non-blocking forwarding to plain-HTTP upstreams; hot reload of routes, upstreams and
[middleware]onSIGHUP. rws --checkvalidates a config file; fixed inline middleware tables being ignored (an auth bypass), global[middleware], IPv6 IP filters.- HTTP/1.1: request heads read across reads (up to 64 KiB), pipelined requests kept, chunked bodies decoded.
- Static files: open-file cache,
sendfilefor plain-TCP downloads, quick requests run on the event loop. - Compression skips small, incompressible and already-encoded bodies.
- Docker image no longer crashes at startup; published to Docker Hub with a smoke test and a
/datavolume.
17.112.0 — 2026-10-24
- RFC 9110 byte ranges;
Content-Rangeonly on206, now over HTTP/2 and HTTP/3 too. - Every setting works from
rws.config.toml; unknown keys warn instead of vanishing. - Config-driven proxy auth fails closed instead of serving the route unauthenticated.
- Automatic HTTPS (ACME) from one config section, verified against Pebble.
- The documented proxy config no longer returns
502; proxied responses no longer break HTTP/2.
17.111.0 — 2026-10-23
- Prebuilt binaries, Homebrew tap, GHCR image,
cargo binstall;rws --version/--help.
17.110.0 — 2026-10-23
- Directory listing for static directories without
index.html; opt-in SPA fallback. - Ingress: TLS to the API server, watch API,
pathType: Exact, IngressClass filtering. - Service discovery: Consul, etcd, Docker labels, DNS SRV / weighted DNS.
- Canary: smooth weighted round-robin, live weight updates; circuit breaker wired into
ReverseProxy. - Secret references (
vault://,aws-sm://,azkv://);Response::json/Response::text. - WebAssembly guest component (
wasm32-wasip2) with outbound HTTP and streaming bodies. - Helm chart.
Earlier
See the GitHub releases and the git tags.