diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md index 033fd571837..bb7380a1d32 100644 --- a/.oss-scanner/threat_model.md +++ b/.oss-scanner/threat_model.md @@ -19,7 +19,7 @@ Treat everything a peer sends as hostile, before and after authentication: The relay is deployed on the public internet as shared infrastructure: one process serves many unrelated tenants, most of them anonymous or holding a token from a third party's auth server. Attack these properties: - **Untrusted network input, bounded cost.** Whatever a peer sends, the memory, CPU, and state it makes the other side hold must be bounded. A peer that can grow relay memory or CPU without bound, past the configured per-session limits (`rs/moq-relay/src/limits.rs`), is a bug even when it takes many messages to do. Fan-out is the multiplier to look for: one publisher's bytes, or one subscriber's request, reaching many sessions or pinning cache. -- **Encryption.** Every transport is TLS 1.3 via rustls with aws-lc: QUIC and WebTransport, and WebSocket over `[web.https]`. Certificates are loaded from disk, issued by ACME (`rs/moq-relay/src/acme.rs`, TLS-ALPN-01), or self-signed and pinned by the fingerprint at `/certificate.sha256`. Check whether the relay accepts 0-RTT, and if it does, whether early data can be replayed. Plain `[web.http]` is a documented development option, not a finding. +- **Encryption.** Every transport is TLS 1.3 via rustls with aws-lc: QUIC and WebTransport, and WebSocket over `[web.https]`. Certificates are loaded from disk or self-signed and pinned by the fingerprint at `/certificate.sha256`. Check whether the relay accepts 0-RTT, and if it does, whether early data can be replayed. Plain `[web.http]` is a documented development option, not a finding. - **Authentication.** Sessions are admitted by a JWT (`moq-auth`, which accepts HMAC, RSA, ECDSA and EdDSA keys, so algorithm confusion and key selection are in scope), by an external auth server over HTTP (`doc/bin/relay/auth.md`), by a verified client certificate weighed by that server, or by a static anonymous grant. Expiry, revalidation, and in-band renewal must never extend a grant past what the server issued, and a failure to reach the auth server must refuse rather than admit. - **Multi-tenancy.** A grant's `root` scopes a session to one subtree; `mounts` add read-only views. A session must never publish, subscribe, receive announcements, fetch, or see stats outside its grant, whether through path normalization, pattern matching (`moq-pattern`), the HTTP endpoints, cluster forwarding, or a revalidation that changes the grant mid-session. One tenant must not be able to degrade another beyond its fair share, for example by exhausting a shared cache or a shared connection limit. - **Cache integrity.** The relay caches and forwards groups by broadcast, track, and group. A publisher must not be able to overwrite or inject into a broadcast it was not granted, and a subscriber must never receive one tenant's data under another tenant's name. diff --git a/Cargo.lock b/Cargo.lock index 751beac327b..f919cc92d6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -426,18 +426,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - [[package]] name = "async-compression" version = "0.4.48" @@ -450,37 +438,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-http-codec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "096146020b08dbc4587685b0730a7ba905625af13c65f8028035cdfd69573c91" -dependencies = [ - "anyhow", - "futures", - "http", - "httparse", - "log", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix 1.1.6", - "slab", - "windows-sys 0.61.2", -] - [[package]] name = "async-lock" version = "3.4.2" @@ -492,17 +449,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-net" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" -dependencies = [ - "async-io", - "blocking", - "futures-lite", -] - [[package]] name = "async-recursion" version = "1.1.1" @@ -514,12 +460,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.92" @@ -531,25 +471,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "async-web-client" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8caf502b44d6d4be6154ac33af012cbb5fef11e6066edcfb42834217fbaf501b" -dependencies = [ - "async-http-codec", - "async-net", - "futures", - "futures-rustls", - "http", - "lazy_static", - "log", - "rustls-pki-types", - "serde", - "thiserror 1.0.69", - "webpki-roots 0.26.11", -] - [[package]] name = "atoi" version = "3.1.0" @@ -903,19 +824,6 @@ dependencies = [ "objc2 0.6.5", ] -[[package]] -name = "blocking" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - [[package]] name = "boytacean" version = "0.13.2" @@ -2140,7 +2048,7 @@ dependencies = [ "once_cell", "pkcs8 0.10.2", "rand 0.9.5", - "rcgen 0.14.10", + "rcgen", "sec1 0.7.3", "signature 2.2.0", "spki 0.7.3", @@ -2634,17 +2542,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "futures-rustls" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" -dependencies = [ - "futures-io", - "rustls", - "rustls-pki-types", -] - [[package]] name = "futures-sink" version = "0.3.34" @@ -5015,7 +4912,7 @@ dependencies = [ "qlog", "rand 0.10.3", "rand_pcg", - "rcgen 0.14.10", + "rcgen", "ring", "rustc-hash 2.1.3", "rustls", @@ -5040,7 +4937,6 @@ name = "moq-relay" version = "0.17.2" dependencies = [ "anyhow", - "async-trait", "aws-lc-rs", "axum", "axum-server", @@ -5052,7 +4948,6 @@ dependencies = [ "hex", "http-body", "http-cache-reqwest", - "humantime", "hyper", "hyper-util", "kio", @@ -5069,11 +4964,10 @@ dependencies = [ "prometheus-client", "qmux 0.7.0", "rand 0.10.3", - "rcgen 0.14.10", + "rcgen", "reqwest", "reqwest-middleware", "rustls", - "rustls-acme", "sd-notify", "serde", "serde_json", @@ -5084,7 +4978,6 @@ dependencies = [ "tempfile", "test-env", "thiserror 2.0.21", - "time", "tokio", "tokio-rustls", "toml 1.1.8+spec-1.1.0", @@ -5095,7 +4988,6 @@ dependencies = [ "usage-rs", "web-transport-trait", "workspace-hack", - "x509-parser", ] [[package]] @@ -5315,7 +5207,7 @@ dependencies = [ "notify", "qmux 0.7.0", "rand 0.10.3", - "rcgen 0.14.10", + "rcgen", "reqwest", "rustc-hash 2.1.3", "rustls", @@ -5344,7 +5236,7 @@ dependencies = [ "usage-rs", "web-transport-proto", "web-transport-trait", - "webpki-roots 1.0.11", + "webpki-roots", "workspace-hack", "x509-parser", ] @@ -5391,7 +5283,7 @@ dependencies = [ "moq-tokio", "moq-uring", "nix", - "rcgen 0.14.10", + "rcgen", "rustc-hash 2.1.3", "rustls", "rustls-platform-verifier", @@ -6782,17 +6674,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - [[package]] name = "pipewire" version = "0.10.1" @@ -7175,7 +7056,7 @@ dependencies = [ "moq-net", "moq-quic", "qmux 0.6.5", - "rcgen 0.14.10", + "rcgen", "rustls", "thiserror 2.0.21", "tokio", @@ -7441,19 +7322,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "rcgen" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" -dependencies = [ - "aws-lc-rs", - "pem 3.0.6", - "rustls-pki-types", - "time", - "yasna 0.5.2", -] - [[package]] name = "rcgen" version = "0.14.10" @@ -7466,7 +7334,7 @@ dependencies = [ "rustls-pki-types", "time", "x509-parser", - "yasna 0.6.0", + "yasna", ] [[package]] @@ -7867,32 +7735,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-acme" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64f24daec4ed0dfe6327bcb2b8045bf1d1cb4835e4f824d4cf487140f4b0cee4" -dependencies = [ - "async-io", - "async-trait", - "async-web-client", - "aws-lc-rs", - "base64 0.22.1", - "blocking", - "chrono", - "futures", - "futures-rustls", - "http", - "log", - "pem 3.0.6", - "rcgen 0.13.2", - "serde", - "serde_json", - "thiserror 2.0.21", - "webpki-roots 1.0.11", - "x509-parser", -] - [[package]] name = "rustls-native-certs" version = "0.8.5" @@ -10616,15 +10458,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.11", -] - [[package]] name = "webpki-roots" version = "1.0.11" @@ -11225,7 +11058,6 @@ dependencies = [ "futures-core", "futures-executor", "futures-io", - "futures-lite", "futures-sink", "futures-task", "futures-util", @@ -11284,7 +11116,7 @@ dependencies = [ "rand 0.8.8", "rand 0.9.5", "rand_chacha 0.3.1", - "rcgen 0.14.10", + "rcgen", "regex", "regex-automata", "regex-syntax", @@ -11339,7 +11171,6 @@ dependencies = [ "windows-sys 0.61.2", "winnow 0.7.15", "winnow 1.0.4", - "x509-parser", "zerocopy", "zeroize", ] @@ -11466,15 +11297,6 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" -[[package]] -name = "yasna" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" -dependencies = [ - "time", -] - [[package]] name = "yasna" version = "0.6.0" diff --git a/demo/relay/prod.toml b/demo/relay/prod.toml index fe4ca26be31..c82dc3191d5 100644 --- a/demo/relay/prod.toml +++ b/demo/relay/prod.toml @@ -25,13 +25,6 @@ listen = "[::]:443" cert = "cert.pem" key = "key.pem" -# Or let the relay fetch and renew a Let's Encrypt certificate for both -# endpoints: remove both cert/key pairs and uncomment this. Let's Encrypt -# validates over web.https, which must be reachable on TCP port 443. -# [acme] -# domains = ["relay.example.com"] -# cache = "/var/lib/moq-relay/acme" - # See docs/auth.md for more information. [auth] # The auth server asked once per session event. The policy lives there: diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index e6a6ec30f95..faf47dde4b0 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -241,7 +241,7 @@ listen = "[::]:4443" # HTTP: fingerprint, announced, fetch, heal [web.https] listen = "[::]:443" # HTTPS, the WebSocket fallback, and tls:// with the listen certificate. -cert = "cert.pem" # cert, key, and root need listen. Omit cert and key with [acme]. +cert = "cert.pem" # cert, key, and root need listen. key = "key.pem" [internal] @@ -255,38 +255,6 @@ keep the connection open; a WebSocket upgrade hands its lifetime to MoQ. See [HTTP endpoints](/bin/relay/http). -## \[acme] - -The relay provisions and renews a publicly trusted certificate itself, for QUIC -and HTTPS alike (Unix only, where the key can be kept owner-only). This needs -the default `acme` Cargo feature; builds without it refuse configured issuance: - -```toml -[acme] -domains = ["relay.example.com"] # Enables ACME. No wildcards. -contact = ["mailto:ops@example.com"] # Optional account contact. -cache = "/var/lib/moq-relay/acme" # Required: account key and certificate, kept owner-only. -# directory = "https://acme-staging-v02.api.letsencrypt.org/directory" # Default: Let's Encrypt production. -# timeout = "5m" # How long the first issuance may take before startup fails. -``` - -Validation is TLS-ALPN-01 only: the directory connects to TCP port 443 of each -domain, so `web.https.listen` is required and must be reachable there, directly -or forwarded. `[acme]` cannot be combined with `listen.tls.cert`, `key`, or -`generate`, or with `web.https.cert` and `key`. - -With no usable certificate in `cache` (missing, expired, missing a domain, or -from another directory), startup answers only the challenge until one is -issued; QUIC, the other routes, and readiness wait for it, and startup fails -with the ACME error once `timeout` runs out. It also fails after 3 failed -validations within an hour, counting earlier starts and renewals recorded in -`cache`, so a restart loop stops short of Let's Encrypt's limit of 5 per -hostname per hour. Renewal runs in the background well before expiry and -reaches every listener on its next handshake. A failed renewal keeps the -current certificate serving and retries, pausing once the same budget is spent -until the oldest failure is an hour old; a restart in the meantime still serves -the cached certificate. - ## \[stun] Build with `cargo install moq-relay --features stun` to use the relay as an diff --git a/doc/bin/relay/index.md b/doc/bin/relay/index.md index ed6344349e5..bdce59e3039 100644 --- a/doc/bin/relay/index.md +++ b/doc/bin/relay/index.md @@ -87,9 +87,7 @@ relay.with_web(web).run().await?; `ConnectInfo` extractor. It is the accepted socket, never a forwarding header, so behind a proxy it is the proxy. - `run` drains on SIGTERM or SIGINT from the moment it serves. Before that they - keep their default action, and still do after a startup error. With ACME, they - are caught from the first issuance on instead, which they end, so a container - can be stopped while it waits for a certificate. To own the signals, for + keep their default action, and still do after a startup error. To own the signals, for example to leave DNS before draining, call `with_signals(false)` and fire `shutdown_trigger()` yourself. The drain reaches only MoQ sessions, so stop any extra listener (RTMP, SRT, ...) on your own deadline. diff --git a/doc/setup/prod.md b/doc/setup/prod.md index 43ac32268e0..f0b871e3b5f 100644 --- a/doc/setup/prod.md +++ b/doc/setup/prod.md @@ -9,7 +9,7 @@ A public relay needs a reachable UDP port, a trusted certificate, and an explicit access policy. Start from the local config and change these: 1. Give the relay a stable hostname and forward its UDP port (QUIC and WebTransport). Forward TCP too if you enable HTTPS or the WebSocket fallback. -2. Get a publicly trusted certificate, ideally from the relay's own `[acme]` client. Generated certificates and disabled verification are for development. +2. Get a publicly trusted certificate from an external client or certificate manager, such as certbot or cert-manager. Generated certificates and disabled verification are for development. 3. Configure [authentication](/bin/relay/auth). Leave nothing anonymous unless you mean to. 4. Keep the operational endpoints (`/metrics`, `/nodes`) on the `[internal]` listener, bound to loopback or a private network. 5. Raise Linux UDP socket buffers (below). @@ -18,15 +18,15 @@ explicit access policy. Start from the local config and change these: [listen] bind = "[::]:443" -# HTTPS and WebSocket fallback on TCP, which also answers the ACME challenge. +[listen.tls] +cert = "/etc/letsencrypt/live/relay.example.com/fullchain.pem" +key = "/etc/letsencrypt/live/relay.example.com/privkey.pem" + +# HTTPS and WebSocket fallback on TCP, using the same certificate as QUIC. [web.https] listen = "[::]:443" - -# A Let's Encrypt certificate for QUIC and HTTPS, renewed in the background. -[acme] -domains = ["relay.example.com"] -contact = ["mailto:ops@example.com"] -cache = "/var/lib/moq-relay/acme" +cert = "/etc/letsencrypt/live/relay.example.com/fullchain.pem" +key = "/etc/letsencrypt/live/relay.example.com/privkey.pem" [auth] url = "http://127.0.0.1:4440/" @@ -44,26 +44,8 @@ moq auth serve --key /etc/moq/public.jwk It verifies each session's JWT; a session without one is refused. See [Authentication](/bin/relay/auth) to define another access policy. -Let's Encrypt validates the domain by connecting to TCP port 443, so -`web.https.listen` must be reachable there, directly or forwarded. The first -start waits for the certificate before serving anything; later starts reuse -the one kept in `dir`. Test against the staging directory first, since -production rate-limits failed attempts. See [`[acme]`](/bin/relay/config#acme). - -To bring your own certificate instead (certbot, cert-manager, a private CA), -drop `[acme]` and point `listen.tls` and `web.https` at the files: - -```toml -[listen.tls] -cert = "/etc/letsencrypt/live/relay.example.com/fullchain.pem" -key = "/etc/letsencrypt/live/relay.example.com/privkey.pem" - -[web.https] -listen = "[::]:443" -cert = "/etc/letsencrypt/live/relay.example.com/fullchain.pem" -key = "/etc/letsencrypt/live/relay.example.com/privkey.pem" -``` - +The certificate manager owns issuance and renewal. Give the relay read access +to the certificate chain and private key files. Certificate files are watched and reloaded for new connections. See the [configuration reference](/bin/relay/config) for every section. diff --git a/nix/modules/moq-relay.nix b/nix/modules/moq-relay.nix index 824b5860dd8..747179a717e 100644 --- a/nix/modules/moq-relay.nix +++ b/nix/modules/moq-relay.nix @@ -74,39 +74,6 @@ in }; }; - acme = { - domains = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - example = [ "relay.example.com" ]; - description = '' - Fetch and renew a certificate for these domains from an ACME directory - (Let's Encrypt by default), kept under `stateDir`. The directory validates - over HTTPS on `httpsPort`, which must be reachable as TCP port 443. - ''; - }; - - contact = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - example = [ "mailto:ops@example.com" ]; - description = "Contact URLs registered with the ACME account"; - }; - - directory = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "https://acme-staging-v02.api.letsencrypt.org/directory"; - description = "ACME directory URL (Let's Encrypt production if null)"; - }; - - httpsPort = lib.mkOption { - type = lib.types.port; - default = 443; - description = "HTTPS listener port, which answers the ACME challenge"; - }; - }; - auth = { enable = lib.mkOption { type = lib.types.bool; @@ -211,10 +178,6 @@ in assertion = cfg.auth.enable || cfg.auth.publicPath != null; message = "services.moq-relay: set auth.enable or auth.publicPath; a relay admits nobody without one"; } - { - assertion = cfg.acme.domains == [ ] || (cfg.tls.certs == [ ] && cfg.tls.generate == [ ]); - message = "services.moq-relay: acme.domains cannot be combined with tls.certs or tls.generate"; - } { assertion = cfg.cluster.mode != "leaf" || cfg.cluster.rootUrl != null; message = "services.moq-relay: cluster.mode = \"leaf\" needs cluster.rootUrl; a leaf without one runs standalone"; @@ -279,13 +242,10 @@ in systemd.services.moq-relay = { description = "Media over QUIC relay server"; wantedBy = [ "multi-user.target" ]; - # ACME validates inbound right away, so wait until the host is reachable. after = [ "network.target" ] - ++ lib.optional cfg.auth.enable "moq-auth.service" - ++ lib.optional (cfg.acme.domains != [ ]) "network-online.target"; - wants = lib.optional (cfg.acme.domains != [ ]) "network-online.target"; + ++ lib.optional cfg.auth.enable "moq-auth.service"; preStart = '' # Generate cluster token for leaf nodes @@ -306,7 +266,7 @@ in ''; serviceConfig = { - # The relay reports ready only once it serves, which with ACME waits for issuance. + # The relay reports ready only once it serves. Type = "notify"; User = cfg.user; Group = cfg.group; @@ -331,9 +291,7 @@ in ReadWritePaths = [ cfg.stateDir ]; # Network capabilities for binding to ports < 1024 - AmbientCapabilities = lib.optional ( - cfg.port < 1024 || (cfg.acme.domains != [ ] && cfg.acme.httpsPort < 1024) - ) "CAP_NET_BIND_SERVICE"; + AmbientCapabilities = lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE"; }; environment = { @@ -357,17 +315,6 @@ in // lib.optionalAttrs (cfg.tls.certs != [ ]) { MOQ_LISTEN_TLS_KEY = lib.concatMapStringsSep "," (cert: "${cert.key}") cfg.tls.certs; } - // lib.optionalAttrs (cfg.acme.domains != [ ]) { - MOQ_ACME_DOMAINS = lib.concatStringsSep "," cfg.acme.domains; - MOQ_ACME_CACHE = "${cfg.stateDir}/acme"; - MOQ_WEB_HTTPS_LISTEN = "[::]:${toString cfg.acme.httpsPort}"; - } - // lib.optionalAttrs (cfg.acme.contact != [ ]) { - MOQ_ACME_CONTACT = lib.concatStringsSep "," cfg.acme.contact; - } - // lib.optionalAttrs (cfg.acme.directory != null) { - MOQ_ACME_DIRECTORY = cfg.acme.directory; - } // lib.optionalAttrs cfg.auth.enable { # The local auth server decides every session. MOQ_AUTH_URL = authUrl; diff --git a/packaging/moq-relay/relay.toml b/packaging/moq-relay/relay.toml index 2fe700f5b21..801d3b409e5 100644 --- a/packaging/moq-relay/relay.toml +++ b/packaging/moq-relay/relay.toml @@ -24,14 +24,6 @@ listen = "[::]:443" cert = "/var/lib/moq-relay/cert.pem" key = "/var/lib/moq-relay/key.pem" -# Or let the relay fetch and renew a Let's Encrypt certificate itself: delete -# both cert/key pairs above and uncomment this. Let's Encrypt validates over -# web.https on TCP port 443, so it must be reachable from the internet. -# [acme] -# domains = ["relay.example.com"] -# contact = ["mailto:ops@example.com"] -# cache = "/var/lib/moq-relay/acme" - # Uncomment to also serve plain HTTP/WebSocket on port 80. # [web.http] # listen = "[::]:80" diff --git a/quest/m1/poll-first/no-spawn/README.md b/quest/m1/poll-first/no-spawn/README.md index 2a142fed161..434503cc2b0 100644 --- a/quest/m1/poll-first/no-spawn/README.md +++ b/quest/m1/poll-first/no-spawn/README.md @@ -41,8 +41,7 @@ Impact section describes migration for the published crates whose API changes. Wire: none. These spawns stay and belong to [the spawn audit](/quest/m2/spawn-audit.md): -`Critical`'s ACME renewal (`rs/moq-relay/src/relay.rs`) and web certificate -reload (`rs/moq-relay/src/web.rs`), the io_uring handoff in +`Critical`'s web certificate reload (`rs/moq-relay/src/web.rs`), the io_uring handoff in `rs/moq-relay/src/connection.rs`, and moq-relay's web, shutdown, and uring spawns. Each UniFFI call's task on the bindings' runtime stays too: that is the binding boundary. So does moq-ffi's wake dispatch (decided 2026-11-03, diff --git a/quest/m1/rind/README.md b/quest/m1/rind/README.md index da4633e70b2..53fe9bf3e46 100644 --- a/quest/m1/rind/README.md +++ b/quest/m1/rind/README.md @@ -67,5 +67,4 @@ The epic's own work, once every child has merged: - [Transport on rind](/quest/m1/rind/transport.md) - moq-quic, moq-tokio, moq-uring, moq-rtmp, and qmux take rind, and the `aws-lc-rs`/`ring` features go - [moq-rtc on rind](/quest/m1/rind/rtc.md) - str0m and dimpl run DTLS, SRTP, and STUN on rind -- [moq-relay drops ACME for now](/quest/m1/rind/acme-remove.md) - the `acme` feature and rustls-acme go until ACME returns on rind - [moq-crypto's signing runs on rind](/quest/m1/rind/crypto-sign.md) - Ed25519 signing and keygen leave aws-lc-rs, after moq-crypto lands diff --git a/quest/m1/rind/acme-remove.md b/quest/m1/rind/acme-remove.md deleted file mode 100644 index d1bf10de677..00000000000 --- a/quest/m1/rind/acme-remove.md +++ /dev/null @@ -1,14 +0,0 @@ -# [S] moq-relay drops ACME for now - -## Goal - -moq-relay has no `acme` feature and no rustls-acme dependency, so the migration to rind no longer waits on instant-acme 0.9. Its config and docs no longer offer ACME; a relay serves certificates from files or generates self-signed ones, as without the feature today. - -## Plan - -Decided by the maintainer on 2026-11-03: ACME is a recent feature nobody uses yet, so it is removed until [relay ACME on instant-acme](/quest/m3/relay-acme.md) brings it back on rind. - -- Remove the `acme` feature, its rustls-acme dependency, the ACME config section, and the code behind it. -- Fix every doc and example that mentions it inline. - -Public API: moq-relay's `acme` feature and ACME config fields are removed (breaking for anyone setting them; the title gets `!`). Wire: none. diff --git a/quest/m1/rind/transport.md b/quest/m1/rind/transport.md index 250928368b0..5f9faa72d65 100644 --- a/quest/m1/rind/transport.md +++ b/quest/m1/rind/transport.md @@ -28,8 +28,7 @@ Decided with the maintainer on 2026-10-26; see the - reqwest and axum-server switch to their no-provider features wherever these crates enable them. Decided 2026-10-31 (maintainer): this includes moq-relay's own reqwest, axum-server, and rustls `aws-lc-rs` features, which - no other area owns; rustls-acme leaves with - [ACME](/quest/m1/rind/acme-remove.md). + no other area owns. Built-in ACME and rustls-acme were removed separately. - The roughly 100 test call sites that install aws-lc's provider install rind's. - The startup check runs where moq-tokio's crypto is first configured, and its diff --git a/quest/m2/audit-test-cost.md b/quest/m2/audit-test-cost.md index 29bdd125821..0e0abbb188b 100644 --- a/quest/m2/audit-test-cost.md +++ b/quest/m2/audit-test-cost.md @@ -110,9 +110,8 @@ Check first: completed-but-unaccepted connection cases are distinct. Keep them without adding a timeout knob. [moq-dev/moq#7593](https://github.com/moq-dev/moq/pull/7593) owns ring grouping and fixture startup diagnostics, not test deletion. -- Macro UI compile failures, ACME issuance/cache/rate-limit cases, and - GStreamer reconnects have distinct compiler, TLS, persistence and real - socket boundaries. Their elapsed cost alone is not evidence of redundancy. +- Macro UI compile failures and GStreamer reconnects have distinct compiler + and real socket boundaries. Their elapsed cost alone is not evidence of redundancy. - Classify failures before calling them flakes. October 30 and 31's feature jobs stopped on an unused `moq-tokio::connect::Config::with_url` method; November 1's gate and all-features run aborted on the relay stack, since diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 7c465619f16..e34867733b8 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -27,8 +27,7 @@ doc = false test-support = [] # The optional public STUN Binding listener (`--stun-bind`). stun = ["dep:stun-types"] -default = ["acme", "cluster-lan", "quic", "websocket", "uds"] -acme = ["dep:rustls-acme"] +default = ["cluster-lan", "quic", "websocket", "uds"] # Discover cluster peers on the LAN with mDNS (`--cluster-lan`). cluster-lan = ["moq-tokio/mdns"] jemalloc = ["moq-tokio/jemalloc"] @@ -54,7 +53,6 @@ _uring = ["dep:moq-sock", "dep:moq-uring"] [dependencies] anyhow = { workspace = true, features = ["backtrace"] } -async-trait = { workspace = true } aws-lc-rs = { workspace = true } axum = { workspace = true } axum-server = { workspace = true, features = ["tls-rustls"] } @@ -66,7 +64,6 @@ http-body = "1.0.1" # A 1.0 prerelease: the latest stable (0.16) is still on reqwest 0.12 and # reqwest-middleware 0.4, while this line matches reqwest 0.13 below. http-cache-reqwest = { version = "1.0.0-alpha.6", features = ["manager-moka", "reqwest-middleware", "url-standard"], default-features = false } -humantime = { workspace = true } # Reclaims the socket under a WebSocket upgrade. hyper = { workspace = true, optional = true } # `server-auto` reclaims the socket from under the auto builder `axum_server` serves with. @@ -85,7 +82,6 @@ rand = { workspace = true } reqwest = { workspace = true, features = ["rustls"] } reqwest-middleware = "0.5" rustls = { version = "0.23", features = ["aws-lc-rs"], default-features = false } -rustls-acme = { version = "0.15.4", optional = true, default-features = false, features = ["aws-lc-rs", "webpki-roots"] } serde = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true, features = ["json", "base64"] } @@ -100,7 +96,6 @@ tracing = { workspace = true } url = { workspace = true, features = ["serde"] } usage-rs = { workspace = true, features = ["completions", "config"] } web-transport-trait = { workspace = true } -x509-parser = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] moq-sock = { workspace = true, optional = true } @@ -121,12 +116,11 @@ moq-congestion = { path = "../moq-congestion" } moq-net = { path = "../moq-net", features = ["fuzz"] } moq-shaper = { path = "../moq-shaper", features = ["test"] } qmux = { workspace = true, features = ["tcp"] } -rcgen = { workspace = true, features = ["pem", "ring", "x509-parser"] } +rcgen = { workspace = true, features = ["pem", "ring"] } stun-types = { workspace = true } tempfile = { workspace = true } # Runs the tests that need a variable set or unset in a child process given it. test-env = { path = "../test-env" } -time = { workspace = true } tokio = { workspace = true, features = ["test-util"] } tracing-subscriber = { workspace = true } workspace-hack = { path = "../workspace-hack" } diff --git a/rs/moq-relay/MIGRATE.md b/rs/moq-relay/MIGRATE.md index a9afd9b04c5..9708c6063e3 100644 --- a/rs/moq-relay/MIGRATE.md +++ b/rs/moq-relay/MIGRATE.md @@ -5,6 +5,17 @@ Rolling a cluster across a release also follows the [upgrade guide](https://moq. ## Unreleased +### Built-in ACME is removed ([#7803](https://github.com/moq-dev/moq/pull/7803)) + +Remove the `acme` Cargo feature, `[acme]` table, `--acme-*` flags, and `MOQ_ACME_*` variables. +In Rust, remove `Config::acme` and uses of `moq_relay::acme::Config`. +The removed flags, TOML table, and environment variables are refused as unknown settings. +Have an external certificate manager issue and renew the certificate, then set `listen.tls.cert` / `key` and, for HTTPS, `web.https.cert` / `key` to its PEM files; both listeners reload changes. +For local development, `listen.tls.generate` still creates self-signed certificates. + +The NixOS module also removes `services.moq-relay.acme.*`: use `services.moq-relay.tls.certs` for externally managed certificates, or `tls.generate` for development. +If you used `acme.httpsPort`, configure the HTTPS listener separately through the service's `MOQ_WEB_HTTPS_LISTEN`, `MOQ_WEB_HTTPS_CERT`, and `MOQ_WEB_HTTPS_KEY` environment variables, and grant `CAP_NET_BIND_SERVICE` if its port is below 1024. + ### `/nodes` lists peers that are down ([#7776](https://github.com/moq-dev/moq/pull/7776)) The internal `/nodes` endpoint lists every configured outbound peer, not only those with a live session. diff --git a/rs/moq-relay/src/acme.rs b/rs/moq-relay/src/acme.rs deleted file mode 100644 index 1f73f407e5d..00000000000 --- a/rs/moq-relay/src/acme.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Automatic certificate configuration, enabled by the default `acme` feature. - -use serde::{Deserialize, Serialize}; -use std::{path::PathBuf, time::Duration}; -use url::Url; - -#[cfg(feature = "acme")] -mod client; -#[cfg(feature = "acme")] -pub(crate) use client::Acme; - -/// Automatic certificates from an ACME directory, enabled by `acme.domains`. -#[serde_with::serde_as] -#[derive(usage_rs::Args, Clone, Debug, Default, Deserialize, Serialize)] -#[usage(unknown_flags = "error", args_override_self = false)] -#[serde(deny_unknown_fields, default)] -#[non_exhaustive] -pub struct Config { - /// Domains the certificate covers; setting any enables ACME. Wildcards are refused. - /// - /// The directory validates each one over TLS-ALPN-01 on port 443, so every - /// domain must resolve to this relay with `web.https.listen` bound or forwarded there. - #[usage( - long = "acme-domains", - name = "acme-domains", - delimiter = ',', - env = "MOQ_ACME_DOMAINS", - setting = "acme.domains" - )] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[serde_as(as = "serde_with::OneOrMany<_>")] - pub domains: Vec, - - /// Contact URLs registered with the ACME account, e.g. `mailto:ops@example.com`. - #[usage( - long = "acme-contact", - name = "acme-contact", - delimiter = ',', - env = "MOQ_ACME_CONTACT", - setting = "acme.contact" - )] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[serde_as(as = "serde_with::OneOrMany<_>")] - pub contact: Vec, - - /// Cache directory required when domains are configured; keeps the account key and certificate private. - #[usage( - long = "acme-cache", - name = "acme-cache", - env = "MOQ_ACME_CACHE", - setting = "acme.cache" - )] - #[usage(value_hint = usage_rs::ValueHint::DirPath)] - pub cache: Option, - - /// The ACME directory URL, Let's Encrypt production when unset. - #[usage( - long = "acme-directory", - name = "acme-directory", - env = "MOQ_ACME_DIRECTORY", - setting = "acme.directory" - )] - pub directory: Option, - - /// How long the first issuance may take before startup fails, e.g. "5m". Defaults to 5 minutes. - #[usage(long = "acme-timeout", env = "MOQ_ACME_TIMEOUT", setting = "acme.timeout")] - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout: Option, - #[usage(long = "acme-dir", env = "MOQ_ACME_DIR", hide = true)] - #[serde(skip)] - legacy_dir: Option, - #[usage(skip)] - #[serde(rename = "dir", skip_serializing)] - removed_dir: Option, -} - -impl Config { - fn timeout(&self) -> Option { - self.timeout.map(Into::into) - } - - pub(crate) fn deprecated(&self) -> moq_tokio::cli::Deprecated { - let mut found = moq_tokio::cli::Deprecated::default(); - if self.legacy_dir.is_some() { - found.flag("--acme-dir", Some("MOQ_ACME_DIR"), "--acme-cache"); - } - if self.removed_dir.is_some() { - found.toml("[acme] dir", "[acme] cache", None); - } - found - } - - #[cfg(not(feature = "acme"))] - pub(crate) fn refuse_unsupported(&self) -> anyhow::Result<()> { - anyhow::ensure!( - self.domains.is_empty() - && self.contact.is_empty() - && self.cache.is_none() - && self.directory.is_none() - && self.timeout().is_none(), - "acme settings require the acme feature" - ); - Ok(()) - } -} diff --git a/rs/moq-relay/src/acme/client.rs b/rs/moq-relay/src/acme/client.rs deleted file mode 100644 index aafdf9c6fa0..00000000000 --- a/rs/moq-relay/src/acme/client.rs +++ /dev/null @@ -1,480 +0,0 @@ -//! Automatic certificates from an ACME directory, Let's Encrypt by default. -//! -//! [rustls-acme](rustls_acme) owns the account, order, and renewal loop and -//! answers TLS-ALPN-01 challenges from its certificate resolver. The relay hands -//! that resolver to every TLS listener, so QUIC, HTTPS, and every worker serve -//! the same certificate and pick up a renewal on their next handshake. - -use kio::clock::Wall; -use std::{ - io, - net::SocketAddr, - path::{Path, PathBuf}, - sync::Arc, - time::Duration, -}; - -use anyhow::Context as _; -use futures::StreamExt; -use rustls::pki_types::{CertificateDer, ServerName, pem::PemObject}; -use rustls_acme::{AcmeState, EventOk, OrderError}; -use url::Url; - -/// How long the first issuance may take by default before startup fails. -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5 * 60); - -/// How long a validation connection may take to finish its handshake. -const CHALLENGE_TIMEOUT: Duration = Duration::from_secs(10); - -/// How long each systemd start-timeout extension lasts while issuance is pending. -#[cfg(unix)] -const EXTEND_USEC: u32 = 90_000_000; - -/// How often the start timeout is extended, well inside [`EXTEND_USEC`]. -const EXTEND_INTERVAL: Duration = Duration::from_secs(30); - -/// Challenge handshakes in flight at once; connections past this are closed unanswered, -/// so a flood cannot exhaust descriptors. Far above the handful of vantage points a -/// directory validates from, so idle scanners do not crowd the validator out. -const CHALLENGE_CONCURRENCY: usize = 1024; - -/// Failed validations tolerated within [`FAILURE_WINDOW`] before issuance refuses to start -/// and renewal pauses, leaving headroom under Let's Encrypt's limit of 5 per hostname per hour. -const FAILURE_LIMIT: usize = 3; - -/// How long a failed validation counts against [`FAILURE_LIMIT`], matching Let's Encrypt's window. -const FAILURE_WINDOW: Duration = Duration::from_secs(60 * 60); - -use super::Config; - -impl Config { - /// Validate against the listeners it must serve through and start the client, or `None` when unconfigured. - pub(crate) fn init( - &self, - listen: &moq_tokio::tls::Listen, - https: &crate::web::Https, - ) -> anyhow::Result> { - if self.domains.is_empty() { - anyhow::ensure!( - self.contact.is_empty() && self.cache.is_none() && self.directory.is_none() && self.timeout().is_none(), - "acme settings require acme.domains" - ); - return Ok(None); - } - if cfg!(not(unix)) { - anyhow::bail!("acme is only supported on unix, where acme.cache can be kept owner-only"); - } - // One canonical set, so reordering or recasing the same names reuses the cached certificate. - let mut domains: Vec = self.domains.iter().map(|domain| domain.to_ascii_lowercase()).collect(); - domains.sort(); - domains.dedup(); - for domain in &domains { - anyhow::ensure!( - !domain.contains('*'), - "acme.domains cannot include the wildcard {domain}: TLS-ALPN-01 cannot issue one" - ); - anyhow::ensure!( - matches!(ServerName::try_from(domain.as_str()), Ok(ServerName::DnsName(_))), - "acme.domains entry {domain:?} is not a DNS name" - ); - } - anyhow::ensure!( - listen.cert.is_empty() && listen.key.is_empty() && listen.generate.is_empty(), - "acme cannot be combined with listen.tls.cert, listen.tls.key, or listen.tls.generate" - ); - anyhow::ensure!( - https.listen.is_some(), - "acme requires web.https.listen: the directory validates each domain over TLS-ALPN-01 on port 443" - ); - anyhow::ensure!( - https.cert.is_empty() && https.key.is_empty(), - "acme cannot be combined with web.https.cert or web.https.key" - ); - let dir = self - .cache - .clone() - .context("acme requires acme.cache to keep the account and certificate across restarts")?; - create_private_dir(&dir).with_context(|| format!("failed to prepare acme.cache {}", dir.display()))?; - - let directory = self - .directory - .as_ref() - .map_or(rustls_acme::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY, Url::as_str); - let cache = Cache { dir }; - let failures = Failures { - path: cache.path("failures", &domains, directory), - }; - let cert = cache.path("cert", &domains, directory); - let state = rustls_acme::AcmeConfig::new(&domains) - .contact(self.contact.iter().map(Url::as_str)) - .directory(directory) - .cache(cache) - .state(); - - Ok(Some(Acme { - state, - failures, - cert, - domains: domains.join(", "), - timeout: self.timeout().unwrap_or(DEFAULT_TIMEOUT), - })) - } -} - -/// The ACME client: the certificate resolver plus the order and renewal loop behind it. -pub(crate) struct Acme { - state: AcmeState, - failures: Failures, - /// Where rustls-acme looks for the cached certificate. - cert: PathBuf, - domains: String, - timeout: Duration, -} - -impl Acme { - /// The resolver every TLS listener serves from, which also answers the challenges. - pub(crate) fn resolver(&self) -> Arc { - self.state.resolver() - } - - /// Serve only the TLS-ALPN-01 challenge on `listener` until a certificate is deployed. - /// - /// A valid cached certificate deploys at once with no challenge. Otherwise this - /// orders one, failing with the last ACME error once the startup budget runs out - /// or once [`FAILURE_LIMIT`] validations failed within the hour, counting earlier - /// starts and renewals: a supervisor restarting the relay would otherwise spend - /// the rest of the directory's allowance and lock the hostname out for an hour. - pub(crate) async fn issue(&mut self, listener: &std::net::TcpListener) -> anyhow::Result<()> { - let addr = listener.local_addr()?; - let mut failed = self.failures.recent()?; - // A spent budget refuses only an order; the certificate it was renewing still serves. - let spent = failed.len() >= FAILURE_LIMIT; - if spent && !Cache::read(&self.cert)?.is_some_and(|pem| current(&pem)) { - return Err(self.failures.refuse(&failed, &self.domains, addr, None)); - } - - // A second descriptor for the same socket, so the web server keeps the - // original and takes over the queue once this returns. - let listener = tokio::net::TcpListener::from_std(listener.try_clone()?)?; - let challenge = tokio_rustls::TlsAcceptor::from(self.state.challenge_rustls_config()); - let mut handshakes = tokio::task::JoinSet::new(); - let health = moq_tokio::accept::Health::new("acme"); - // Readiness waits on issuance, so keep extending systemd's start timeout - // rather than letting the unit's default kill the relay inside the budget. - let mut extend = kio::clock::interval(EXTEND_INTERVAL); - let deadline = kio::clock::sleep(self.timeout); - tokio::pin!(deadline); - let mut last = None; - let mut issued = false; - - loop { - tokio::select! { - event = self.state.next() => match event.context("ACME client stopped")? { - // Validation never ran, so failed renewals still count against the budget. - Ok(EventOk::DeployedCachedCert) => { - tracing::info!(domains = %self.domains, "serving the cached ACME certificate"); - return Ok(()); - } - // rustls-acme could not deploy the cached certificate, so its next poll orders one. - event if spent => { - let last = event.err().map(|err| err.to_string()); - return Err(self.failures.refuse(&failed, &self.domains, addr, last.as_deref())); - } - // Not ready until it is saved: a certificate lost on restart would be ordered again. - Ok(EventOk::DeployedNewCert) => { - tracing::info!(domains = %self.domains, "issued an ACME certificate"); - issued = true; - } - Ok(EventOk::CertCacheStore) if issued => return self.failures.clear(), - Ok(event) => tracing::debug!(?event, "ACME"), - Err(rustls_acme::EventError::CertCacheStore(err)) => { - return Err(anyhow::Error::new(err).context("failed to save the issued ACME certificate in acme.cache")); - } - // The directory failed a validation, or may still fail one rustls-acme stopped waiting on. - Err(err @ rustls_acme::EventError::Order(OrderError::BadAuth(_) | OrderError::TooManyAttemptsAuth(_))) => { - failed = self.failures.record()?; - tracing::warn!(%err, domains = %self.domains, failed = failed.len(), "ACME validation failed"); - if failed.len() >= FAILURE_LIMIT { - return Err(self.failures.refuse(&failed, &self.domains, addr, Some(&err.to_string()))); - } - last = Some(err.to_string()); - } - Err(err) => { - tracing::warn!(%err, domains = %self.domains, "ACME issuance failed; retrying"); - last = Some(err.to_string()); - } - }, - accepted = listener.accept() => match accepted { - Ok((stream, _)) => { - health.accepted(); - if handshakes.len() < CHALLENGE_CONCURRENCY { - let handshake = challenge.accept(stream); - // The validator only needs the handshake, then closes. - handshakes.spawn(async move { - let _ = kio::clock::timeout(CHALLENGE_TIMEOUT, handshake).await; - }); - } - } - Err(err) => { - if let Some(pause) = health.failed(&err) { - kio::clock::sleep(pause).await; - } - } - }, - Some(_) = handshakes.join_next() => {} - _ = extend.tick() => { - #[cfg(unix)] - let _ = sd_notify::notify(&[sd_notify::NotifyState::ExtendTimeoutUsec(EXTEND_USEC)]); - } - _ = &mut deadline => { - let last = last.as_deref().unwrap_or("no response from the directory"); - anyhow::bail!( - "no ACME certificate for {} within {}: {last}; the directory validates each domain over TLS-ALPN-01 on port 443, and web.https.listen is {addr}", - self.domains, - humantime::format_duration(self.timeout), - ); - } - } - } - } - - /// Renew in the background forever, keeping the current certificate serving through failures. - /// - /// Failed validations count against the same budget as [`Self::issue`], since - /// rustls-acme retries a failed order within seconds. Once [`FAILURE_LIMIT`] fall - /// within [`FAILURE_WINDOW`], renewal stops polling the ACME state, which pauses - /// rustls-acme's order loop, until the oldest one ages out. - /// - /// Never returns: the event stream is infinite, so its end panics, which stops - /// the relay through its critical tasks instead of silently ending renewal. - pub(crate) async fn run(self) { - renew(self.state, self.failures, self.domains).await - } -} - -/// The renewal loop over `events`, split from [`Acme::run`] so tests can drive it with fake events. -async fn renew( - mut events: impl futures::Stream> + Unpin, - failures: Failures, - domains: String, -) { - loop { - let resume = match failures.recent() { - Ok(failed) if failed.len() < FAILURE_LIMIT => None, - Ok(failed) => failed.first().map(|oldest| oldest.saturating_add(FAILURE_WINDOW)), - // An unknown budget counts as spent, rather than risk the directory's limit. - Err(err) => { - tracing::error!(err = format!("{err:#}"), %domains, "failed to read the ACME failure record"); - Some(kio::clock::wall().saturating_add(FAILURE_WINDOW)) - } - }; - if let Some(resume) = resume { - tracing::warn!( - %domains, - resume = %humantime::format_rfc3339_seconds(resume.into()), - "ACME renewal paused after {FAILURE_LIMIT} failed validations within the hour, short of the directory's limit; still serving the previous certificate" - ); - kio::clock::sleep(resume.saturating_duration_since(kio::clock::wall())).await; - continue; - } - - match events.next().await.expect("the ACME event stream ended") { - Ok(EventOk::DeployedNewCert) => { - tracing::info!(%domains, "renewed the ACME certificate"); - if let Err(err) = failures.clear() { - tracing::error!(%err, %domains, "failed to clear the ACME failure record"); - } - } - Ok(event) => tracing::debug!(?event, "ACME"), - // Counted as in issue. - Err(err @ rustls_acme::EventError::Order(OrderError::BadAuth(_) | OrderError::TooManyAttemptsAuth(_))) => { - match failures.record() { - Ok(failed) => tracing::warn!( - %err, - %domains, - failed = failed.len(), - "ACME renewal validation failed; still serving the previous certificate" - ), - // Unrecorded, it would never count, so assume the budget is spent. - Err(record) => { - tracing::error!(%err, record = format!("{record:#}"), %domains, "ACME renewal validation failed and could not be recorded; pausing renewal for an hour"); - kio::clock::sleep(FAILURE_WINDOW).await; - } - } - } - Err(err) => tracing::error!( - %err, - %domains, - "ACME renewal failed; still serving the previous certificate" - ), - } - } -} - -/// Create `dir` if needed and restrict it to the owner, since it holds private keys. -fn create_private_dir(dir: &Path) -> io::Result<()> { - std::fs::create_dir_all(dir)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?; - } - Ok(()) -} - -/// The failed validations recorded in `acme.cache`, so restarts share one allowance. -/// -/// One Unix timestamp per line, keyed like the certificate it was trying to issue. -struct Failures { - path: PathBuf, -} - -impl Failures { - /// The failures within [`FAILURE_WINDOW`], oldest first. - fn recent(&self) -> anyhow::Result> { - let Some(contents) = Cache::read(&self.path)? else { - return Ok(Vec::new()); - }; - let cutoff = kio::clock::wall() - .checked_sub(FAILURE_WINDOW) - .unwrap_or(Wall::UNIX_EPOCH); - let mut recent = Vec::new(); - for line in String::from_utf8_lossy(&contents).lines() { - let secs: u64 = line - .parse() - .with_context(|| format!("malformed ACME failure record in {}", self.path.display()))?; - let at = Wall::UNIX_EPOCH - .checked_add(Duration::from_secs(secs)) - .context("failure timestamp out of range")?; - if at > cutoff { - recent.push(at); - } - } - recent.sort(); - Ok(recent) - } - - /// Record a failure now, returning the recent ones including it. - fn record(&self) -> anyhow::Result> { - let mut recent = self.recent()?; - recent.push(kio::clock::wall()); - let lines: String = recent - .iter() - .map(|at| format!("{}\n", at.saturating_duration_since(Wall::UNIX_EPOCH).as_secs())) - .collect(); - Cache::write(&self.path, lines.as_bytes())?; - Ok(recent) - } - - fn clear(&self) -> anyhow::Result<()> { - match std::fs::remove_file(&self.path) { - Err(err) if err.kind() != io::ErrorKind::NotFound => Err(err.into()), - _ => Ok(()), - } - } - - /// The startup error once `failed` reaches [`FAILURE_LIMIT`]. - fn refuse(&self, failed: &[Wall], domains: &str, addr: SocketAddr, last: Option<&str>) -> anyhow::Error { - let retry = failed - .first() - .map_or(Some(kio::clock::wall()), |oldest| oldest.checked_add(FAILURE_WINDOW)) - .map(|retry| humantime::format_rfc3339_seconds(retry.into()).to_string()) - .unwrap_or_else(|| "the supported wall-time range".to_string()); - let last = last.map(|last| format!("; last error: {last}")).unwrap_or_default(); - anyhow::anyhow!( - "ACME validation for {domains} failed {} times in the last hour; stopping before the directory's failed-validation limit locks the hostname out (Let's Encrypt allows 5 failed validations per hostname per hour). Check that each domain resolves to this relay and that TCP port 443 reaches web.https.listen ({addr}), then restart after {} or delete {}{last}", - failed.len(), - retry, - self.path.display(), - ) - } -} - -/// Keeps the account key and certificate in `dir`, one owner-only file each. -/// -/// Files are keyed by a hash of the contacts or domains plus the directory URL, so -/// a certificate missing a configured domain or issued by another directory is -/// simply not found and gets reissued. An expired one counts as missing too. -struct Cache { - dir: PathBuf, -} - -impl Cache { - fn path(&self, kind: &str, names: &[String], directory: &str) -> PathBuf { - let mut digest = aws_lc_rs::digest::Context::new(&aws_lc_rs::digest::SHA256); - for name in names { - digest.update(name.as_bytes()); - digest.update(&[0]); - } - digest.update(directory.as_bytes()); - self.dir.join(format!("{kind}-{}", hex::encode(digest.finish()))) - } - - fn read(path: &Path) -> io::Result>> { - match std::fs::read(path) { - Ok(contents) => Ok(Some(contents)), - Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), - Err(err) => Err(err), - } - } - - /// Replace `path` atomically, so a crash mid-write never leaves a torn key behind. - fn write(path: &Path, contents: &[u8]) -> io::Result<()> { - use std::io::Write; - - let partial = path.with_extension("partial"); - let mut options = std::fs::OpenOptions::new(); - options.write(true).create(true).truncate(true); - #[cfg(unix)] - std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); - let mut file = options.open(&partial)?; - file.write_all(contents)?; - file.sync_all()?; - std::fs::rename(partial, path) - } -} - -/// Whether the leaf in a cached key-and-chain PEM is valid right now. -fn current(pem: &[u8]) -> bool { - let Some(Ok(leaf)) = CertificateDer::pem_slice_iter(pem).next() else { - return false; - }; - x509_parser::parse_x509_certificate(&leaf).is_ok_and(|(_, cert)| cert.validity().is_valid()) -} - -#[async_trait::async_trait] -impl rustls_acme::CertCache for Cache { - type EC = io::Error; - - async fn load_cert(&self, domains: &[String], directory: &str) -> io::Result>> { - let cached = Self::read(&self.path("cert", domains, directory))?; - Ok(cached.filter(|pem| { - let current = current(pem); - if !current { - tracing::info!("the cached ACME certificate is expired or unreadable; reissuing"); - } - current - })) - } - - async fn store_cert(&self, domains: &[String], directory: &str, cert: &[u8]) -> io::Result<()> { - Self::write(&self.path("cert", domains, directory), cert) - } -} - -#[async_trait::async_trait] -impl rustls_acme::AccountCache for Cache { - type EA = io::Error; - - async fn load_account(&self, contact: &[String], directory: &str) -> io::Result>> { - Self::read(&self.path("account", contact, directory)) - } - - async fn store_account(&self, contact: &[String], directory: &str, account: &[u8]) -> io::Result<()> { - Self::write(&self.path("account", contact, directory), account) - } -} - -#[cfg(all(test, unix))] -#[path = "tests.rs"] -mod tests; diff --git a/rs/moq-relay/src/acme/tests.rs b/rs/moq-relay/src/acme/tests.rs deleted file mode 100644 index 4bac1b68347..00000000000 --- a/rs/moq-relay/src/acme/tests.rs +++ /dev/null @@ -1,870 +0,0 @@ -//! ACME end to end against an in-process directory that really validates TLS-ALPN-01. -//! -//! These cross real sockets and rustls-acme's own wall-clock polling, so they -//! run on the wall clock and bound every wait with [`TIMEOUT`]. - -use std::{ - net::SocketAddr, - sync::{ - Mutex, - atomic::{AtomicUsize, Ordering}, - }, -}; - -use axum::{ - Router, - extract::{Path as Route, State}, - http::{StatusCode, header}, - response::{IntoResponse, Response}, - routing::{get, post}, -}; -use serde::Deserialize; -use serde_json::{Value, json}; - -use super::*; -use crate::{Config as RelayConfig, Relay}; - -const DOMAIN: &str = "relay.test"; -const TIMEOUT: Duration = Duration::from_secs(30); - -/// A minimal ACME directory over plain HTTP. -/// -/// It skips JWS verification, which is rustls-acme's half of the protocol, and -/// keeps the relay's half honest: every challenge is a real TLS-ALPN-01 -/// handshake against the relay's HTTPS listener, checked for the -/// `acmeIdentifier` extension before the authorization turns valid. -struct Directory { - url: Url, - state: Arc, - task: tokio::task::JoinHandle<()>, -} - -struct Server { - base: String, - ca: rcgen::Issuer<'static, rcgen::KeyPair>, - ca_der: CertificateDer<'static>, - ca_pem: String, - /// The relay's HTTPS listener, where every domain "resolves". - target: Mutex>, - orders: Mutex>, - /// Successful TLS-ALPN-01 validations. - validated: AtomicUsize, - /// Failed TLS-ALPN-01 validations. - failed: AtomicUsize, - /// Certificates handed out, in order. - issued: tokio::sync::watch::Sender>>, - /// Orders refused once this many certificates were issued. - refuse_after: AtomicUsize, - refused: tokio::sync::watch::Sender, - /// Issue the first certificate already due for renewal. - due_first: bool, -} - -struct Order { - domains: Vec, - /// Each authorization: `None` pending, then whether validation succeeded. - valid: Vec>, - cert: Option, -} - -impl Drop for Directory { - fn drop(&mut self) { - self.task.abort(); - } -} - -impl Directory { - async fn start(due_first: bool) -> Self { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base = format!("http://{}", listener.local_addr().unwrap()); - - let ca_key = rcgen::KeyPair::generate().unwrap(); - let mut params = rcgen::CertificateParams::new(Vec::new()).unwrap(); - params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - params - .distinguished_name - .push(rcgen::DnType::CommonName, "moq test ACME"); - let ca = params.self_signed(&ca_key).unwrap(); - - let state = Arc::new(Server { - base: base.clone(), - ca: rcgen::Issuer::new(params, ca_key), - ca_der: ca.der().clone(), - ca_pem: ca.pem(), - target: Mutex::new(None), - orders: Mutex::new(Vec::new()), - validated: AtomicUsize::new(0), - failed: AtomicUsize::new(0), - issued: tokio::sync::watch::channel(Vec::new()).0, - refuse_after: AtomicUsize::new(usize::MAX), - refused: tokio::sync::watch::channel(0).0, - due_first, - }); - let app = Router::new() - .route("/directory", get(directory)) - .route("/nonce", get(nonce)) - .route("/account", post(account)) - .route("/order", post(new_order)) - .route("/order/{n}", post(order)) - .route("/authz/{n}/{i}", post(authz)) - .route("/challenge/{n}/{i}", post(challenge)) - .route("/finalize/{n}", post(finalize)) - .route("/cert/{n}", post(certificate)) - .with_state(state.clone()); - let task = tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - Self { - url: format!("{base}/directory").parse().unwrap(), - state, - task, - } - } - - fn issued(&self) -> Vec> { - self.state.issued.borrow().clone() - } - - #[cfg(all(target_os = "linux", feature = "_quic"))] - async fn wait_issued(&self, count: usize) -> Vec> { - let mut issued = self.state.issued.subscribe(); - kio::clock::timeout(TIMEOUT, issued.wait_for(|issued| issued.len() >= count)) - .await - .expect("the directory never issued") - .unwrap() - .clone() - } -} - -impl Server { - fn order_json(&self, n: usize, order: &Order) -> Value { - let base = &self.base; - let status = match &order.cert { - Some(_) => "valid", - None if order.valid.contains(&Some(false)) => "invalid", - None if order.valid.iter().all(|valid| *valid == Some(true)) => "ready", - None => "pending", - }; - let mut json = json!({ - "status": status, - "authorizations": (0..order.domains.len()).map(|i| format!("{base}/authz/{n}/{i}")).collect::>(), - "finalize": format!("{base}/finalize/{n}"), - }); - if order.cert.is_some() { - json["certificate"] = format!("{base}/cert/{n}").into(); - } - json - } - - /// Dial the relay as the domain and check it answers with a challenge certificate. - async fn validate(&self, domain: &str) -> bool { - let target = self.target.lock().unwrap().expect("no relay to validate against"); - let mut tls = rustls::ClientConfig::builder_with_provider(moq_tokio::crypto::provider()) - .with_safe_default_protocol_versions() - .unwrap() - .dangerous() - .with_custom_certificate_verifier(Arc::new(Validator)) - .with_no_client_auth(); - tls.alpn_protocols = vec![rustls_acme::acme::ACME_TLS_ALPN_NAME.to_vec()]; - let Ok(stream) = tokio::net::TcpStream::connect(target).await else { - return false; - }; - let name = ServerName::try_from(domain.to_string()).unwrap(); - let Ok(stream) = tokio_rustls::TlsConnector::from(Arc::new(tls)) - .connect(name, stream) - .await - else { - return false; - }; - let (_, conn) = stream.get_ref(); - let Some(leaf) = conn.peer_certificates().and_then(|chain| chain.first()) else { - return false; - }; - let (_, cert) = x509_parser::parse_x509_certificate(leaf).unwrap(); - conn.alpn_protocol() == Some(rustls_acme::acme::ACME_TLS_ALPN_NAME) - && cert - .extensions() - .iter() - .any(|ext| ext.oid.to_id_string() == "1.3.6.1.5.5.7.1.31") - } -} - -/// Accepts any certificate, since a challenge certificate is self-signed and -/// carries a critical extension webpki refuses; [`Server::validate`] checks it instead. -#[derive(Debug)] -struct Validator; - -impl rustls::client::danger::ServerCertVerifier for Validator { - fn verify_server_cert( - &self, - _: &CertificateDer<'_>, - _: &[CertificateDer<'_>], - _: &ServerName<'_>, - _: &[u8], - _: rustls::pki_types::UnixTime, - ) -> Result { - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - _: &[u8], - _: &CertificateDer<'_>, - _: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _: &[u8], - _: &CertificateDer<'_>, - _: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - moq_tokio::crypto::provider() - .signature_verification_algorithms - .supported_schemes() - } -} - -/// The decoded JWS payload, `Null` for a POST-as-GET. -fn payload(body: &str) -> Value { - #[serde_with::serde_as] - #[derive(Deserialize)] - struct Jws { - #[serde_as(as = "serde_with::base64::Base64")] - payload: Vec, - } - let jws: Jws = serde_json::from_str(body).unwrap(); - match jws.payload.is_empty() { - true => Value::Null, - false => serde_json::from_slice(&jws.payload).unwrap(), - } -} - -fn created(location: String, body: Value) -> Response { - (StatusCode::CREATED, [(header::LOCATION, location)], axum::Json(body)).into_response() -} - -async fn directory(State(state): State>) -> axum::Json { - let base = &state.base; - axum::Json(json!({ - "newNonce": format!("{base}/nonce"), - "newAccount": format!("{base}/account"), - "newOrder": format!("{base}/order"), - })) -} - -async fn nonce() -> impl IntoResponse { - [("replay-nonce", "nonce")] -} - -async fn account(State(state): State>) -> Response { - created(format!("{}/account/1", state.base), json!({ "status": "valid" })) -} - -async fn new_order(State(state): State>, body: String) -> Response { - if state.issued.borrow().len() >= state.refuse_after.load(Ordering::Relaxed) { - state.refused.send_modify(|refused| *refused += 1); - return (StatusCode::SERVICE_UNAVAILABLE, "refusing orders").into_response(); - } - let domains: Vec = payload(&body)["identifiers"] - .as_array() - .unwrap() - .iter() - .map(|id| id["value"].as_str().unwrap().to_string()) - .collect(); - let mut orders = state.orders.lock().unwrap(); - let order = Order { - valid: vec![None; domains.len()], - domains, - cert: None, - }; - let n = orders.len(); - let json = state.order_json(n, &order); - orders.push(order); - created(format!("{}/order/{n}", state.base), json) -} - -async fn order(State(state): State>, Route(n): Route) -> axum::Json { - let orders = state.orders.lock().unwrap(); - axum::Json(state.order_json(n, &orders[n])) -} - -async fn authz(State(state): State>, Route((n, i)): Route<(usize, usize)>) -> axum::Json { - let orders = state.orders.lock().unwrap(); - let order = &orders[n]; - axum::Json(json!({ - "status": match order.valid[i] { - None => "pending", - Some(true) => "valid", - Some(false) => "invalid", - }, - "identifier": { "type": "dns", "value": order.domains[i] }, - "challenges": [{ - "type": "tls-alpn-01", - "url": format!("{}/challenge/{n}/{i}", state.base), - "token": format!("token-{n}-{i}"), - }], - })) -} - -async fn challenge(State(state): State>, Route((n, i)): Route<(usize, usize)>) -> axum::Json { - let domain = state.orders.lock().unwrap()[n].domains[i].clone(); - // Like a real directory, one failed attempt invalidates the authorization. - let valid = state.validate(&domain).await; - match valid { - true => state.validated.fetch_add(1, Ordering::Relaxed), - false => state.failed.fetch_add(1, Ordering::Relaxed), - }; - state.orders.lock().unwrap()[n].valid[i] = Some(valid); - axum::Json(json!({ - "type": "tls-alpn-01", - "url": format!("{}/challenge/{n}/{i}", state.base), - "token": format!("token-{n}-{i}"), - "status": if valid { "valid" } else { "invalid" }, - })) -} - -async fn finalize(State(state): State>, Route(n): Route, body: String) -> axum::Json { - #[serde_with::serde_as] - #[derive(Deserialize)] - struct Finalize { - #[serde_as(as = "serde_with::base64::Base64")] - csr: Vec, - } - let request: Finalize = serde_json::from_value(payload(&body)).unwrap(); - let mut csr = rcgen::CertificateSigningRequestParams::from_der(&request.csr.into()).unwrap(); - let now = time::OffsetDateTime::now_utc(); - let first = state.issued.borrow().is_empty(); - // Already two thirds through its life, which rustls-acme renews at once. - (csr.params.not_before, csr.params.not_after) = match first && state.due_first { - true => (now - time::Duration::hours(1), now + time::Duration::minutes(10)), - false => (now - time::Duration::hours(1), now + time::Duration::days(90)), - }; - let leaf = csr.signed_by(&state.ca).unwrap(); - let chain = format!("{}{}", leaf.pem(), state.ca_pem); - - let mut orders = state.orders.lock().unwrap(); - orders[n].cert = Some(chain); - state.issued.send_modify(|issued| issued.push(leaf.der().clone())); - axum::Json(state.order_json(n, &orders[n])) -} - -async fn certificate(State(state): State>, Route(n): Route) -> String { - state.orders.lock().unwrap()[n].cert.clone().unwrap() -} - -/// A relay that takes its certificate from `directory` for `domains`, keeping state in `dir`. -fn relay_config(directory: &Url, dir: &Path, domains: &[&str]) -> RelayConfig { - let mut config = RelayConfig::default(); - config.drain_timeout = Some((Duration::ZERO).into()); - config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); - config.web.https.listen = Some("127.0.0.1:0".parse().unwrap()); - config.auth.public = vec![moq_auth::Pattern::all()]; - config.acme.domains = domains.iter().map(|domain| domain.to_string()).collect(); - config.acme.cache = Some(dir.to_path_buf()); - config.acme.directory = Some(directory.clone()); - config -} - -/// A relay serving after its first certificate deployed. -struct Running { - https: SocketAddr, - quic: SocketAddr, - trigger: crate::shutdown::Trigger, - task: tokio::task::JoinHandle>, -} - -impl Running { - async fn start(config: RelayConfig, directory: &Directory) -> Self { - let _ = moq_tokio::crypto::install_default(); - let relay = Relay::load(config).await.unwrap().with_signals(false); - let https = relay.web_addrs().https.unwrap(); - let quic = relay.quic_addr().unwrap(); - *directory.state.target.lock().unwrap() = Some(https); - let ready = relay.ready(); - let trigger = relay.shutdown_trigger().clone(); - let task = tokio::spawn(relay.run()); - kio::clock::timeout(TIMEOUT, ready.wait()) - .await - .expect("the relay never became ready") - .unwrap(); - Self { - https, - quic, - trigger, - task, - } - } - - async fn stop(self) { - self.trigger.start(); - self.task.await.unwrap().unwrap(); - } - - /// The leaf a fresh HTTPS handshake serves, trusting only the test CA for `domain`. - async fn served(&self, directory: &Directory, domain: &str) -> CertificateDer<'static> { - let mut roots = rustls::RootCertStore::empty(); - roots.add(directory.state.ca_der.clone()).unwrap(); - let tls = rustls::ClientConfig::builder_with_provider(moq_tokio::crypto::provider()) - .with_safe_default_protocol_versions() - .unwrap() - .with_root_certificates(roots) - .with_no_client_auth(); - let stream = tokio::net::TcpStream::connect(self.https).await.unwrap(); - let name = ServerName::try_from(domain.to_string()).unwrap(); - let stream = tokio_rustls::TlsConnector::from(Arc::new(tls)) - .connect(name, stream) - .await - .expect("the HTTPS listener must serve the issued certificate"); - stream.get_ref().1.peer_certificates().unwrap()[0].clone().into_owned() - } - - /// Wait for HTTPS to serve `leaf`, which deploys just after the directory hands it out. - #[cfg(all(target_os = "linux", feature = "_quic"))] - async fn wait_served(&self, directory: &Directory, leaf: &CertificateDer<'static>) { - kio::clock::timeout(TIMEOUT, async { - while self.served(directory, DOMAIN).await != *leaf { - kio::clock::sleep(Duration::from_millis(20)).await; - } - }) - .await - .expect("the relay never served the renewed certificate"); - } - - /// Open a QUIC session pinned to `leaf`, which only succeeds if QUIC serves it. - async fn quic_serves(&self, leaf: &CertificateDer<'static>) { - let mut config = moq_tokio::connect::Config::default(); - config.tls.fingerprint = vec![fingerprint(leaf)]; - config.bind = Some("127.0.0.1:0".parse().unwrap()); - let client = config.init(Default::default()).unwrap().with_reconnect(false); - let url: Url = format!("moql://127.0.0.1:{}/", self.quic.port()).parse().unwrap(); - let _session = kio::clock::timeout(TIMEOUT, client.with_reconnect(false).connect(url).established()) - .await - .expect("QUIC connect timed out") - .expect("QUIC must serve the same certificate as HTTPS"); - } -} - -/// A port that refuses every connection for as long as the socket lives. -/// -/// Bound but never listening, so a dial is refused at once. A released port -/// would instead be free for another test process to bind, and a listener -/// there that never answers stalls the dial past every budget. -fn refusing() -> (tokio::net::TcpSocket, SocketAddr) { - let socket = tokio::net::TcpSocket::new_v4().unwrap(); - socket.bind("127.0.0.1:0".parse().unwrap()).unwrap(); - let addr = socket.local_addr().unwrap(); - assert!( - std::net::TcpListener::bind(addr).is_err(), - "no other listener can take the refusing port" - ); - (socket, addr) -} - -fn fingerprint(leaf: &CertificateDer<'_>) -> String { - hex::encode(aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, leaf)) -} - -/// The first start issues over the HTTPS acceptor, and QUIC serves the same certificate. -#[tokio::test] -async fn first_start_issues_over_https() { - let directory = Directory::start(false).await; - let dir = tempfile::tempdir().unwrap(); - let relay = Running::start(relay_config(&directory.url, dir.path(), &[DOMAIN]), &directory).await; - - let issued = directory.issued(); - assert_eq!(issued.len(), 1); - assert_eq!(directory.state.validated.load(Ordering::Relaxed), 1); - assert_eq!(relay.served(&directory, DOMAIN).await, issued[0]); - relay.quic_serves(&issued[0]).await; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777; - assert_eq!(mode(dir.path()), 0o700); - let files: Vec<_> = std::fs::read_dir(dir.path()) - .unwrap() - .map(|entry| entry.unwrap().path()) - .collect(); - assert_eq!(files.len(), 2, "one account key and one certificate: {files:?}"); - for file in files { - assert_eq!(mode(&file), 0o600, "{} must be owner-only", file.display()); - } - } - relay.stop().await; -} - -/// A restart with a valid cache serves at once with no challenge, and a cache that -/// misses a domain or came from another directory reissues. -#[tokio::test] -async fn restart_reuses_only_a_matching_cache() { - let directory = Directory::start(false).await; - let dir = tempfile::tempdir().unwrap(); - let first = Running::start(relay_config(&directory.url, dir.path(), &[DOMAIN]), &directory).await; - let leaf = directory.issued()[0].clone(); - first.stop().await; - - let restarted = Running::start(relay_config(&directory.url, dir.path(), &[DOMAIN]), &directory).await; - assert_eq!(directory.issued().len(), 1, "a valid cache must not reissue"); - assert_eq!(directory.state.validated.load(Ordering::Relaxed), 1); - assert_eq!(restarted.served(&directory, DOMAIN).await, leaf); - restarted.stop().await; - - // The same names recased and repeated are one certificate, not a new order. - let recased = Running::start( - relay_config(&directory.url, dir.path(), &["RELAY.test", DOMAIN]), - &directory, - ) - .await; - assert_eq!(directory.issued().len(), 1, "a recased domain set must reuse the cache"); - recased.stop().await; - - let widened = Running::start( - relay_config(&directory.url, dir.path(), &[DOMAIN, "other.test"]), - &directory, - ) - .await; - assert_eq!(directory.issued().len(), 2, "a cache missing a domain must reissue"); - assert_eq!(directory.state.validated.load(Ordering::Relaxed), 3); - assert_eq!(widened.served(&directory, "other.test").await, directory.issued()[1]); - widened.stop().await; - - let other = Directory::start(false).await; - let moved = Running::start(relay_config(&other.url, dir.path(), &[DOMAIN]), &other).await; - assert_eq!(other.issued().len(), 1, "a cache from another directory must reissue"); - assert_eq!(moved.served(&other, DOMAIN).await, other.issued()[0]); - moved.stop().await; -} - -/// An unreachable directory fails the first start inside the budget, naming the -/// domain and the HTTPS listener. -#[tokio::test] -async fn unreachable_directory_fails_inside_the_budget() { - let (_refusing, closed) = refusing(); - let url: Url = format!("http://{closed}/directory").parse().unwrap(); - let dir = tempfile::tempdir().unwrap(); - let mut config = relay_config(&url, dir.path(), &[DOMAIN]); - let budget = Duration::from_secs(2); - config.acme.timeout = Some((budget).into()); - - let relay = Relay::load(config).await.unwrap().with_signals(false); - let https = relay.web_addrs().https.unwrap(); - let ready = relay.ready(); - let started = kio::clock::now(); - let err = kio::clock::timeout(TIMEOUT, relay.run()) - .await - .expect("the budget must bound the first issuance") - .expect_err("no certificate means no relay"); - assert!(started.elapsed() >= budget); - let message = format!("{err:#}"); - assert!(message.contains(DOMAIN), "{message}"); - assert!(message.contains(&https.to_string()), "{message}"); - assert!( - ready.wait().await.is_err(), - "a relay without a certificate never reports ready" - ); -} - -/// A stop signal during the first issuance ends startup, since a container's -/// PID 1 ignores one left at its default action until issuance gives up. -#[cfg(unix)] -#[tokio::test(start_paused = true)] -async fn a_signal_during_issuance_stops_startup() { - let _signals = crate::shutdown::SIGNALS.lock().await; - let (_refusing, closed) = refusing(); - let url: Url = format!("http://{closed}/directory").parse().unwrap(); - let dir = tempfile::tempdir().unwrap(); - let mut config = relay_config(&url, dir.path(), &[DOMAIN]); - config.acme.timeout = Some((Duration::from_secs(60 * 60)).into()); - - let relay = Relay::load(config).await.unwrap(); - let ready = relay.ready(); - let run = relay.run(); - tokio::pin!(run); - // Nothing awaits before issuance, so one poll leaves `run` waiting on it. - assert!(futures::FutureExt::now_or_never(run.as_mut()).is_none()); - crate::shutdown::raise(libc::SIGTERM).await; - let started = kio::clock::now(); - run.await.expect("a stop during issuance is not a failure"); - assert_eq!(started.elapsed(), Duration::ZERO, "waited out the issuance"); - assert!(ready.wait().await.is_err(), "reported ready without a certificate"); -} - -/// Repeated failed validations stop the first issuance short of Let's Encrypt's -/// failed-validation limit, and a restart inside the hour does not try again. -#[tokio::test] -async fn failed_validations_stop_short_of_the_rate_limit() { - let directory = Directory::start(false).await; - // Every validation is refused, like an unforwarded port 443. - let (_refusing, closed) = refusing(); - *directory.state.target.lock().unwrap() = Some(closed); - let dir = tempfile::tempdir().unwrap(); - let start = || async { - let mut config = relay_config(&directory.url, dir.path(), &[DOMAIN]); - config.acme.timeout = Some((TIMEOUT).into()); - let relay = Relay::load(config).await.unwrap().with_signals(false); - let err = kio::clock::timeout(TIMEOUT, relay.run()) - .await - .expect("failed validations must end startup before the budget") - .expect_err("no certificate means no relay"); - format!("{err:#}") - }; - - let message = start().await; - assert_eq!(directory.state.failed.load(Ordering::Relaxed), FAILURE_LIMIT); - assert!( - message.contains("5 failed validations per hostname per hour"), - "{message}" - ); - assert!(message.contains(DOMAIN), "{message}"); - - let orders = directory.state.orders.lock().unwrap().len(); - let message = start().await; - assert_eq!( - directory.state.orders.lock().unwrap().len(), - orders, - "a restart within the hour must not order again" - ); - assert!( - message.contains("5 failed validations per hostname per hour"), - "{message}" - ); -} - -/// A renewal reaches HTTPS and every QUIC worker through fresh handshakes, without a restart. -#[cfg(all(target_os = "linux", feature = "_quic"))] -#[tokio::test] -async fn renewal_reaches_every_worker() { - let directory = Directory::start(true).await; - let dir = tempfile::tempdir().unwrap(); - let mut config = relay_config(&directory.url, dir.path(), &[DOMAIN]); - config.runtime.workers = Some(2); - config.runtime.pin = false; - let relay = Running::start(config, &directory).await; - - let issued = directory.wait_issued(2).await; - assert_ne!(issued[0], issued[1]); - relay.wait_served(&directory, &issued[1]).await; - // Each dial takes a fresh source port, which the kernel spreads over the workers. - for _ in 0..8 { - relay.quic_serves(&issued[1]).await; - } - relay.stop().await; -} - -/// A failed renewal leaves the previous certificate serving. -#[tokio::test] -async fn failed_renewal_keeps_serving() { - let directory = Directory::start(true).await; - directory.state.refuse_after.store(1, Ordering::Relaxed); - let dir = tempfile::tempdir().unwrap(); - let relay = Running::start(relay_config(&directory.url, dir.path(), &[DOMAIN]), &directory).await; - - let mut refused = directory.state.refused.subscribe(); - kio::clock::timeout(TIMEOUT, refused.wait_for(|refused| *refused >= 1)) - .await - .expect("the relay never tried to renew") - .unwrap(); - let leaf = directory.issued()[0].clone(); - assert_eq!(relay.served(&directory, DOMAIN).await, leaf); - relay.quic_serves(&leaf).await; - relay.stop().await; -} - -/// A key and self-signed leaf for [`DOMAIN`] in the cache's PEM layout, valid until `not_after`. -fn cached_pem(not_after: time::OffsetDateTime) -> String { - let key = rcgen::KeyPair::generate().unwrap(); - let mut params = rcgen::CertificateParams::new(vec![DOMAIN.to_string()]).unwrap(); - params.not_before = not_after - time::Duration::days(90); - params.not_after = not_after; - format!("{}{}", key.serialize_pem(), params.self_signed(&key).unwrap().pem()) -} - -/// An expired cached certificate counts as missing rather than being served. -#[tokio::test] -async fn expired_cache_counts_as_missing() { - use rustls_acme::CertCache; - - let dir = tempfile::tempdir().unwrap(); - let cache = Cache { - dir: dir.path().to_path_buf(), - }; - let domains = vec![DOMAIN.to_string()]; - let now = time::OffsetDateTime::now_utc(); - - let expired = cached_pem(now - time::Duration::minutes(1)); - cache.store_cert(&domains, "dir", expired.as_bytes()).await.unwrap(); - assert_eq!(cache.load_cert(&domains, "dir").await.unwrap(), None); - - let current = cached_pem(now + time::Duration::days(1)); - cache.store_cert(&domains, "dir", current.as_bytes()).await.unwrap(); - assert_eq!( - cache.load_cert(&domains, "dir").await.unwrap().as_deref(), - Some(current.as_bytes()) - ); -} - -/// A client for [`DOMAIN`] keeping state in `dir`, whose directory is never contacted. -fn uncontacted(dir: &Path) -> Acme { - let mut config = RelayConfig::default(); - config.web.https.listen = Some("127.0.0.1:0".parse().unwrap()); - config.acme.domains = vec![DOMAIN.into()]; - config.acme.cache = Some(dir.to_path_buf()); - config.acme.directory = Some("http://127.0.0.1:1/directory".parse().unwrap()); - config - .acme - .init(&config.listen.tls, &config.web.https) - .unwrap() - .unwrap() -} - -/// What rustls-acme yields when the directory marks an authorization invalid. -fn bad_auth() -> rustls_acme::EventError { - rustls_acme::EventError::Order(rustls_acme::OrderError::BadAuth(rustls_acme::acme::Auth { - status: rustls_acme::acme::AuthStatus::Invalid, - identifier: rustls_acme::acme::Identifier::Dns(DOMAIN.into()), - challenges: Vec::new(), - })) -} - -/// Failed renewal validations stop ordering at the startup budget, which a start -/// inside the window honors too, and renewal resumes once the oldest leaves it. -#[kio::test] -async fn renewal_pauses_once_failed_validations_spend_the_budget() { - let dir = tempfile::tempdir().unwrap(); - let mut acme = uncontacted(dir.path()); - let polled = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); - // rustls-acme retries a failed order within seconds; these come back at once. - let events = futures::stream::iter([ - Err(bad_auth()), - // The directory may still fail an authorization rustls-acme stopped waiting on. - Err(rustls_acme::EventError::Order( - rustls_acme::OrderError::TooManyAttemptsAuth(DOMAIN.into()), - )), - Err(bad_auth()), - Err(bad_auth()), - Ok(EventOk::DeployedNewCert), - ]) - .chain(futures::stream::pending()) - .inspect({ - let polled = polled.clone(); - move |_| polled.borrow_mut().push(kio::clock::wall()) - }); - let start = kio::clock::wall(); - let failures = Failures { - path: acme.failures.path.clone(), - }; - let renewal = kio::clock::sim::spawn(renew(events, failures, DOMAIN.into())); - - kio::clock::sleep(FAILURE_WINDOW - Duration::from_secs(1)).await; - assert_eq!( - *polled.borrow(), - [start; FAILURE_LIMIT], - "renewal ordered past the budget" - ); - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let err = acme - .issue(&listener) - .await - .expect_err("a start without a cached certificate must not order inside the window"); - let message = format!("{err:#}"); - assert!( - message.contains("5 failed validations per hostname per hour"), - "{message}" - ); - - kio::clock::sleep(Duration::from_secs(1)).await; - kio::clock::sim::idle().await; - let resumed = start + FAILURE_WINDOW; - assert_eq!(*polled.borrow(), [start, start, start, resumed, resumed]); - assert!( - acme.failures.recent().unwrap().is_empty(), - "a renewal clears the failures" - ); - renewal.abort(); -} - -/// A spent budget still starts on a valid cached certificate, and keeps its -/// failures so renewal stays paused instead of ordering again. One rustls-acme -/// cannot deploy refuses instead, since it would order a replacement. -#[tokio::test] -async fn a_spent_budget_still_serves_the_cached_certificate() { - let dir = tempfile::tempdir().unwrap(); - let mut acme = uncontacted(dir.path()); - let pem = cached_pem(time::OffsetDateTime::now_utc() + time::Duration::days(30)); - Cache::write(&acme.cert, pem.as_bytes()).unwrap(); - for _ in 0..FAILURE_LIMIT { - acme.failures.record().unwrap(); - } - - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - listener.set_nonblocking(true).unwrap(); - kio::clock::timeout(TIMEOUT, acme.issue(&listener)) - .await - .expect("the cached certificate deploys at once") - .expect("a valid cached certificate serves despite a spent budget"); - assert_eq!(acme.failures.recent().unwrap().len(), FAILURE_LIMIT); - - // A current leaf without its key, which rustls-acme fails to parse. - let leaf = &pem[pem.find("-----BEGIN CERTIFICATE-----").unwrap()..]; - Cache::write(&acme.cert, leaf.as_bytes()).unwrap(); - let mut acme = uncontacted(dir.path()); - let err = kio::clock::timeout(TIMEOUT, acme.issue(&listener)) - .await - .expect("an undeployable cached certificate must not order past the budget") - .expect_err("an undeployable cached certificate is no certificate"); - let message = format!("{err:#}"); - assert!( - message.contains("5 failed validations per hostname per hour"), - "{message}" - ); -} - -/// What the config refuses before anything binds. -#[test] -fn config_refuses_what_acme_cannot_serve() { - let dir = tempfile::tempdir().unwrap(); - let valid = || { - let mut config = RelayConfig::default(); - config.web.https.listen = Some("127.0.0.1:0".parse().unwrap()); - config.acme.domains = vec![DOMAIN.into()]; - config.acme.cache = Some(dir.path().to_path_buf()); - config - }; - let init = |config: RelayConfig| { - config - .acme - .init(&config.listen.tls, &config.web.https) - .map(|acme| acme.is_some()) - .map_err(|err| err.to_string()) - }; - assert_eq!(init(valid()), Ok(true)); - assert_eq!(init(RelayConfig::default()), Ok(false)); - - type Mutate = fn(&mut RelayConfig); - let cases: [(&str, Mutate); 6] = [ - ("listen.tls.cert", |config| { - config.listen.tls.cert = vec!["cert.pem".into()] - }), - ("web.https.listen", |config| config.web.https.listen = None), - ("web.https.cert", |config| { - config.web.https.cert = vec!["cert.pem".into()] - }), - ("wildcard", |config| config.acme.domains = vec!["*.relay.test".into()]), - ("acme.cache", |config| config.acme.cache = None), - ("require acme.domains", |config| config.acme.domains.clear()), - ]; - for (expected, mutate) in cases { - let mut config = valid(); - mutate(&mut config); - let err = init(config).expect_err(expected); - assert!(err.contains(expected), "{expected}: {err}"); - } -} diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index 877265ff503..5346fcbe328 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -1097,7 +1097,7 @@ impl Started { /// Run the cluster's dials and peer sources on this task until they stop, or /// until one of them or a task the relay cannot serve without (its - /// ACME renewal or certificate reload) panics. The origin, cache governor, and + /// certificate reload) panics. The origin, cache governor, and /// stats publisher are the [`Driver`]'s work. /// /// Standalone, this only waits for such a panic. Returning, a failure @@ -1284,7 +1284,7 @@ pub struct Cluster { /// view always points at the same session in the logs. connection_ids: Arc, - /// ACME renewal and certificate reload: a panic in either stops + /// Certificate reload: a panic stops /// [`Relay::run`](crate::Relay::run). pub(crate) critical: crate::critical::Critical, diff --git a/rs/moq-relay/src/config.rs b/rs/moq-relay/src/config.rs index 1ee853cfac8..8d38f4ca8c7 100644 --- a/rs/moq-relay/src/config.rs +++ b/rs/moq-relay/src/config.rs @@ -85,11 +85,6 @@ pub struct Config { #[serde(default)] pub web: web::Config, - /// Automatic certificates from an ACME directory. Disabled unless `acme.domains` is set. - #[usage(flatten)] - #[serde(default)] - pub acme: crate::acme::Config, - /// Optional public STUN Binding listener, available with the `stun` feature. #[cfg(feature = "stun")] #[usage(flatten)] @@ -322,7 +317,6 @@ impl Config { deprecated.extend(self.connect.deprecated()); deprecated.extend(self.cluster.deprecated()); deprecated.extend(self.auth.deprecated()); - deprecated.extend(self.acme.deprecated()); if let Some(server) = &self.server { deprecated.toml("[server]", "[listen]", None); deprecated.extend(server.deprecated()); @@ -347,8 +341,6 @@ impl Config { // dial clients would otherwise check `[quic]` late or never. `[limits.peer.quic]` // is checked by `Cluster::new`, which embedders call directly. self.quic.validate().context("invalid [quic]")?; - #[cfg(not(feature = "acme"))] - self.acme.refuse_unsupported()?; self.quic .max_streams @@ -363,7 +355,7 @@ mod tests { /// Each renamed setting: its old flag, old variable, a value equal to the old default, /// and the replacement its refusal names. - const OBSOLETE_ROLE_SOURCES: [(&str, &str, &str, &str); 6] = [ + const OBSOLETE_ROLE_SOURCES: [(&str, &str, &str, &str); 5] = [ ( "--session-announces", "MOQ_SESSION_ANNOUNCES", @@ -376,7 +368,6 @@ mod tests { "100000", "--limits-peer-announces", ), - ("--acme-dir", "MOQ_ACME_DIR", "cache", "--acme-cache"), ( "--cluster-connect-api", "MOQ_CLUSTER_CONNECT_API", @@ -429,7 +420,6 @@ mod tests { for (body, replacement) in [ ("[session]\nannounces = 1000", "[limits.client]"), ("[peer]\nannounces = 100000", "[limits.peer]"), - ("[acme]\ndir = 'cache'", "[acme] cache"), ("[cluster]\nconnect_api = 'peers.json'", "[cluster.api] url"), ("[cluster]\nconnect_api_tls_root = []", "[cluster.api.tls] root"), ("[auth]\nverifications = 128", "fixed per-session verification policy"), @@ -441,16 +431,6 @@ mod tests { } } - #[cfg(not(feature = "acme"))] - #[test] - fn an_acme_free_build_refuses_configured_issuance() { - let mut config = Config::default(); - config.resolve().unwrap(); - config.acme.domains.push("relay.example.com".into()); - let error = config.resolve().unwrap_err(); - assert!(error.to_string().contains("acme feature"), "{error}"); - } - /// The moq-transport extension choices are file-only, so the loader must carry them through. #[test] fn loader_keeps_toml_extension_settings() { diff --git a/rs/moq-relay/src/lib.rs b/rs/moq-relay/src/lib.rs index fd896f264e6..ecddea3b5ce 100644 --- a/rs/moq-relay/src/lib.rs +++ b/rs/moq-relay/src/lib.rs @@ -12,7 +12,6 @@ #![warn(missing_docs)] -pub mod acme; pub mod auth; pub mod cache; /// Joining other relays into a cluster that shares one origin. diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index e63f59b41e5..5fea35f591a 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -94,9 +94,6 @@ pub struct Relay { driver: Option, internal: internal::Internal, web: web::Web, - /// The ACME client, issuing before [`Self::run`] serves and renewing after. - #[cfg(feature = "acme")] - acme: Option, #[cfg(feature = "stun")] stun: Option, addr: Option, @@ -157,9 +154,6 @@ impl Relay { .get_or_insert_with(|| format!("moq-relay/{} {}", env!("CARGO_PKG_VERSION"), moq_net::IMPLEMENTATION)) .clone(); config.connect.implementation = Some(implementation.clone()); - // Every TLS listener serves the ACME certificate, so install it before any is built. - #[cfg(feature = "acme")] - let acme = config.acme.init(&config.listen.tls, &config.web.https)?; let resolved_config = Box::new(config.clone()); let drain_timeout = config.drain_timeout(); // The name this relay reports in every auth request: the stats node label, @@ -219,17 +213,7 @@ impl Relay { // Workers and the stream server split one relay across listeners, so they share // one loaded identity. A lone server loads its own, only if a listener needs it. - #[cfg(feature = "acme")] - let resolved = acme - .as_ref() - .map(|acme| moq_tokio::tls::Serve::resolver(acme.resolver(), &config.listen.tls)) - .transpose()?; - #[cfg(not(feature = "acme"))] - let resolved = None; - let served = match resolved { - Some(served) => Some(served), - None => config.runtime.workers().map(|_| config.listen.tls.load()).transpose()?, - }; + let served = config.runtime.workers().map(|_| config.listen.tls.load()).transpose()?; let mut listen = config.listen.clone(); if served.is_some() { listen.tls = Default::default(); @@ -356,8 +340,6 @@ impl Relay { .with_quic(&config.quic) .with_sessions(sessions.clone()) .with_tls_acceptor(server.tls_acceptor()); - #[cfg(feature = "acme")] - let web = web.with_acme(acme.as_ref().map(crate::acme::Acme::resolver)); let web = web.bind()?; // `bind`, not `listen`: the TCP/Unix accept loops handshake as soon as // they run, and `load` is not yet willing to take a session. `run` @@ -410,8 +392,6 @@ impl Relay { driver: Some(driver), internal, web, - #[cfg(feature = "acme")] - acme, #[cfg(feature = "stun")] stun, addr, @@ -578,9 +558,8 @@ impl Relay { /// [`Self::shutdown_trigger`] itself, e.g. after withdrawing the node from DNS. /// /// `run` installs the handlers just before it serves, once its startup can no - /// longer fail, so a signal before that takes its default action. With ACME - /// it installs them before the first issuance instead, so a stop request - /// there still ends `run`. They stay installed for the life of the process, + /// longer fail, so a signal before that takes its default action. + /// They stay installed for the life of the process, /// since tokio never removes one, even after `run` fails. #[must_use = "the relay with the signal choice is returned"] pub fn with_signals(mut self, signals: bool) -> Self { @@ -638,8 +617,6 @@ impl Relay { cluster, internal, web, - #[cfg(feature = "acme")] - acme, #[cfg(feature = "stun")] stun, shutdown, @@ -665,27 +642,6 @@ impl Relay { "no --auth-url or --auth-public configured; nobody can authenticate (an embedder decides by taking Relay::admissions)" ); - let mut handlers = None; - - // Nothing else serves or reports ready until the certificate exists: a - // self-signed placeholder would only fail every browser that connects. - #[cfg(feature = "acme")] - if let Some(mut acme) = acme { - let listener = web.https_listener().context("ACME needs the HTTPS listener")?; - // Issuance can take minutes, and as a container's PID 1 the relay - // ignores a signal left at its default action, so a stop would wait it - // out. That outweighs leaving the handlers installed if a later step fails. - if signals { - handlers = Some(shutdown::Signals::new()?); - } - tokio::select! { - res = acme.issue(listener) => res?, - // No session exists yet, so this returns as soon as it starts. - () = drain(shutdown_trigger.clone(), shutdown.clone(), handlers.as_mut()) => return Ok(()), - } - cluster.critical.spawn("ACME renewal", acme.run()); - } - // Validate the cluster and bind its LAN advertisement before claiming to be // ready: the `cluster.run()` below is first polled after the notify, so a bad // key or an mDNS failure would otherwise release the units depending on a @@ -705,14 +661,12 @@ impl Relay { }) .transpose()?; - // Without ACME, after the last step that can fail, since a handler outlives a + // After the last step that can fail, since a handler outlives a // failed `run`, and before anything accepts a session a signal would kill // without a GOAWAY. // Nothing awaits from here to the drain below, so a signal caught in between // starts the drain at its first poll, as one just after READY would. - if signals && handlers.is_none() { - handlers = Some(shutdown::Signals::new()?); - } + let mut handlers = signals.then(shutdown::Signals::new).transpose()?; #[cfg(all(target_os = "linux", feature = "_uring"))] if let Some(uring) = uring.as_mut() { diff --git a/rs/moq-relay/src/settings.rs b/rs/moq-relay/src/settings.rs index ee621b96b78..436558c2716 100644 --- a/rs/moq-relay/src/settings.rs +++ b/rs/moq-relay/src/settings.rs @@ -38,9 +38,6 @@ pub struct Settings { #[usage(flatten)] web: Web, - #[usage(flatten)] - acme: Acme, - #[usage(flatten)] stats: Stats, @@ -193,25 +190,6 @@ struct WebHttps { root: Option>, } -#[derive(usage_rs::Config)] -#[usage(prefix = "acme")] -struct Acme { - #[usage(env = "MOQ_ACME_DOMAINS", cli("--acme-domains"), parse = "list_by_comma")] - domains: Option>, - - #[usage(env = "MOQ_ACME_CONTACT", cli("--acme-contact"), parse = "list_by_comma")] - contact: Option>, - - #[usage(env = "MOQ_ACME_CACHE", cli("--acme-cache"))] - cache: Option, - - #[usage(env = "MOQ_ACME_DIRECTORY", cli("--acme-directory"))] - directory: Option, - - #[usage(env = "MOQ_ACME_TIMEOUT", cli("--acme-timeout"))] - timeout: Option, -} - #[derive(usage_rs::Config)] #[usage(prefix = "stats")] struct Stats { diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 512e82dd05d..6f3515d91cf 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -90,7 +90,7 @@ pub struct Http { #[serde(deny_unknown_fields, default)] #[non_exhaustive] pub struct Https { - /// Socket address to bind the HTTPS listener to, serving `cert`/`key` or the `[acme]` certificate. + /// Socket address to bind the HTTPS listener to, serving `cert`/`key`. #[usage( long = "web-https-listen", name = "web-https-listen", @@ -198,8 +198,6 @@ pub struct Web { http_listener: Option, https_listener: Option, https_tls: Option, - /// The ACME certificate resolver HTTPS serves instead of `https.cert`/`https.key`. - acme: Option>, rtmp: rtmp::Handoff, /// Serves `tls://` on the HTTPS listener, when the relay has a listen identity. tls_stream: Option, @@ -235,7 +233,6 @@ impl Web { http_listener: None, https_listener: None, https_tls: None, - acme: None, rtmp: rtmp::Handoff::default(), tls_stream: None, addrs: Addrs::default(), @@ -255,10 +252,7 @@ impl Web { } } if self.https_tls.is_none() && self.config.https.listen.is_some() { - let tls = match &self.acme { - Some(resolver) => build_acme_config(resolver.clone(), &self.config.https.root)?, - None => build_https_config(&self.config.https.cert, &self.config.https.key, &self.config.https.root)?, - }; + let tls = build_https_config(&self.config.https.cert, &self.config.https.key, &self.config.https.root)?; self.https_tls = Some(RustlsConfig::from_config(tls)); } if let Some(listen) = self.config.http.listen @@ -282,19 +276,6 @@ impl Web { Ok(self) } - /// Serve HTTPS from the ACME resolver, which also answers TLS-ALPN-01 challenges. - #[cfg(feature = "acme")] - pub(crate) fn with_acme(mut self, resolver: Option>) -> Self { - self.acme = resolver; - self - } - - /// The bound HTTPS socket, where ACME answers challenges before serving starts. - #[cfg(feature = "acme")] - pub(crate) fn https_listener(&self) -> Option<&net::TcpListener> { - self.https_listener.as_ref() - } - /// Take the RTMP and RTMPS connections the HTTP and HTTPS listeners classify. /// /// RTMP opens with a byte no HTTP request or TLS ClientHello starts with, and an @@ -484,7 +465,6 @@ impl Web { http_listener, https_listener, https_tls, - acme, rtmp, tls_stream, .. @@ -519,18 +499,15 @@ impl Web { let https = if let Some(listener) = https_listener { let rustls_config = https_tls.expect("HTTPS config was built before binding"); - // The ACME resolver renews in place, so there are no files to watch. - if acme.is_none() { - state.cluster.critical.spawn( - "HTTPS certificate reload", - reload_https_config( - rustls_config.clone(), - config.https.cert, - config.https.key, - config.https.root, - ), - ); - } + state.cluster.critical.spawn( + "HTTPS certificate reload", + reload_https_config( + rustls_config.clone(), + config.https.cert, + config.https.key, + config.https.root, + ), + ); let mut server = crate::listener::server( listener, crate::listener::Config { @@ -638,22 +615,6 @@ fn build_https_config( .context("failed to build https TLS config") } -/// Build the HTTPS config around the ACME resolver, also offering the TLS-ALPN-01 protocol. -/// -/// [`WebAcceptor`] closes a challenge connection once its handshake completes. -fn build_acme_config( - resolver: Arc, - root: &[PathBuf], -) -> anyhow::Result> { - let mut tls = moq_tokio::tls::Listen::default(); - tls.root = root.to_vec(); - - let alpn = vec![b"h2".to_vec(), b"http/1.1".to_vec(), b"acme-tls/1".to_vec()]; - moq_tokio::tls::Serve::resolver(resolver, &tls)? - .config(alpn) - .context("failed to build https TLS config") -} - fn https_watch_paths(cert: &[PathBuf], key: &[PathBuf], root: &[PathBuf]) -> Vec { cert.iter() .cloned() @@ -829,10 +790,6 @@ impl Accept for WebAcceptor { } }; let tls = stream.tls(); - if tls.is_some_and(|tls| tls.alpn_protocol() == Some(b"acme-tls/1")) { - // The validator checked the certificate during the handshake; RFC 8737 has the server close. - return Err(std::io::Error::other("answered an ACME TLS-ALPN-01 challenge")); - } let peer = tls .and_then(|tls| tls.peer_certificates()) .filter(|certs| !certs.is_empty()) diff --git a/rs/moq-tokio/src/tls.rs b/rs/moq-tokio/src/tls.rs index 9f50d918554..d316df5a4fd 100644 --- a/rs/moq-tokio/src/tls.rs +++ b/rs/moq-tokio/src/tls.rs @@ -2668,7 +2668,7 @@ mod tests { ); } - /// An ACME-style resolver retains CA verification of presented client certificates. + /// An external resolver retains CA verification of presented client certificates. #[cfg(all(feature = "watch", feature = "quic"))] #[tokio::test] async fn external_resolver_retains_client_roots() { diff --git a/rs/workspace-hack/Cargo.toml b/rs/workspace-hack/Cargo.toml index 767776371e0..7eed8186016 100644 --- a/rs/workspace-hack/Cargo.toml +++ b/rs/workspace-hack/Cargo.toml @@ -28,7 +28,6 @@ anyhow = { version = "1", features = ["backtrace"] } aws-lc-rs = { version = "1", features = ["prebuilt-nasm"] } aws-lc-sys = { version = "0.46", default-features = false, features = ["prebuilt-nasm"] } base64 = { version = "0.23" } -bitflags = { version = "2", default-features = false, features = ["std"] } bytemuck = { version = "1", default-features = false, features = ["derive", "extern_crate_alloc", "min_const_generics"] } byteorder = { version = "1" } bytes = { version = "1", features = ["serde"] } @@ -55,7 +54,6 @@ fs-err = { version = "3", default-features = false, features = ["tokio"] } futures-channel = { version = "0.3", features = ["sink"] } futures-core = { version = "0.3" } futures-executor = { version = "0.3" } -futures-io = { version = "0.3" } futures-sink = { version = "0.3" } futures-task = { version = "0.3", default-features = false, features = ["std"] } futures-util = { version = "0.3", features = ["channel", "io", "sink"] } @@ -92,7 +90,7 @@ proptest = { version = "1", default-features = false, features = ["handle-panics rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } rand-c38e5c1d305a1b54 = { package = "rand", version = "0.8" } rand_chacha = { version = "0.3", default-features = false, features = ["std"] } -rcgen = { version = "0.14", default-features = false, features = ["aws_lc_rs", "pem", "ring", "x509-parser"] } +rcgen = { version = "0.14", default-features = false, features = ["aws_lc_rs", "pem", "ring"] } regex-automata = { version = "0.4", default-features = false, features = ["dfa-build", "dfa-onepass", "hybrid", "meta", "nfa-backtrack", "perf-inline", "perf-literal", "std", "unicode"] } regex-syntax = { version = "0.8" } reqwest = { version = "0.13", default-features = false, features = ["gzip", "http2", "json", "rustls", "rustls-no-provider", "stream"] } @@ -133,7 +131,6 @@ usage-argv = { version = "7", default-features = false, features = ["complete", usage-rs = { version = "7", features = ["completions", "config"] } uuid = { version = "1", features = ["v4", "v7"] } winnow-dff4ba8e3ae991db = { package = "winnow", version = "1", features = ["simd"] } -x509-parser = { version = "0.18", features = ["verify", "verify-aws"] } zeroize = { version = "1", features = ["derive"] } [build-dependencies] @@ -141,7 +138,6 @@ anyhow = { version = "1", features = ["backtrace"] } aws-lc-rs = { version = "1", features = ["prebuilt-nasm"] } aws-lc-sys = { version = "0.46", default-features = false, features = ["prebuilt-nasm"] } base64 = { version = "0.23" } -bitflags = { version = "2", default-features = false, features = ["std"] } bytemuck = { version = "1", default-features = false, features = ["derive", "extern_crate_alloc", "min_const_generics"] } byteorder = { version = "1" } bytes = { version = "1", features = ["serde"] } @@ -171,7 +167,6 @@ fs-err = { version = "3", default-features = false, features = ["tokio"] } futures-channel = { version = "0.3", features = ["sink"] } futures-core = { version = "0.3" } futures-executor = { version = "0.3" } -futures-io = { version = "0.3" } futures-sink = { version = "0.3" } futures-task = { version = "0.3", default-features = false, features = ["std"] } futures-util = { version = "0.3", features = ["channel", "io", "sink"] } @@ -210,7 +205,7 @@ quote = { version = "1" } rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } rand-c38e5c1d305a1b54 = { package = "rand", version = "0.8" } rand_chacha = { version = "0.3", default-features = false, features = ["std"] } -rcgen = { version = "0.14", default-features = false, features = ["aws_lc_rs", "pem", "ring", "x509-parser"] } +rcgen = { version = "0.14", default-features = false, features = ["aws_lc_rs", "pem", "ring"] } regex-automata = { version = "0.4", default-features = false, features = ["dfa-build", "dfa-onepass", "hybrid", "meta", "nfa-backtrack", "perf-inline", "perf-literal", "std", "unicode"] } regex-syntax = { version = "0.8" } reqwest = { version = "0.13", default-features = false, features = ["gzip", "http2", "json", "rustls", "rustls-no-provider", "stream"] } @@ -254,10 +249,10 @@ usage-rs = { version = "7", features = ["completions", "config"] } uuid = { version = "1", features = ["v4", "v7"] } winnow-ca01ad9e24f5d932 = { package = "winnow", version = "0.7" } winnow-dff4ba8e3ae991db = { package = "winnow", version = "1", features = ["simd"] } -x509-parser = { version = "0.18", features = ["verify", "verify-aws"] } zeroize = { version = "1", features = ["derive"] } [target.aarch64-apple-darwin.dependencies] +bitflags = { version = "2", default-features = false, features = ["std"] } block2 = { version = "0.6" } crossbeam-epoch = { version = "0.9" } dispatch2 = { version = "0.3" } @@ -279,7 +274,7 @@ objc2-foundation = { version = "0.3" } objc2-metal = { version = "0.3" } objc2-quartz-core = { version = "0.3", default-features = false, features = ["CALayer", "CAMediaTiming", "CAMetalLayer", "objc2-core-foundation", "objc2-core-graphics", "objc2-metal", "std"] } regex = { version = "1" } -rustix = { version = "1", features = ["event", "fs", "net", "pipe", "process", "termios", "time"] } +rustix = { version = "1", features = ["fs", "termios"] } security-framework-sys = { version = "2" } tokio-rustls = { version = "0.26", default-features = false, features = ["aws-lc-rs", "tls12"] } tower = { version = "0.5", default-features = false, features = ["retry", "timeout"] } @@ -287,6 +282,7 @@ tower-http = { version = "0.6", default-features = false, features = ["decompres zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } [target.aarch64-apple-darwin.build-dependencies] +bitflags = { version = "2", default-features = false, features = ["std"] } block2 = { version = "0.6" } crossbeam-epoch = { version = "0.9" } dispatch2 = { version = "0.3" } @@ -308,7 +304,7 @@ objc2-foundation = { version = "0.3" } objc2-metal = { version = "0.3" } objc2-quartz-core = { version = "0.3", default-features = false, features = ["CALayer", "CAMediaTiming", "CAMetalLayer", "objc2-core-foundation", "objc2-core-graphics", "objc2-metal", "std"] } regex = { version = "1" } -rustix = { version = "1", features = ["event", "fs", "net", "pipe", "process", "termios", "time"] } +rustix = { version = "1", features = ["fs", "termios"] } security-framework-sys = { version = "2" } tokio-rustls = { version = "0.26", default-features = false, features = ["aws-lc-rs", "tls12"] } tower = { version = "0.5", default-features = false, features = ["retry", "timeout"] } @@ -316,13 +312,14 @@ tower-http = { version = "0.6", default-features = false, features = ["decompres zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } [target.aarch64-unknown-linux-gnu.dependencies] +bitflags = { version = "2", default-features = false, features = ["std"] } bytemuck = { version = "1", default-features = false, features = ["aarch64_simd"] } crossbeam-epoch = { version = "0.9" } cudarc = { version = "0.19", default-features = false, features = ["cuda-12020", "driver", "fallback-dynamic-loading", "nvrtc"] } event-listener = { version = "5" } event-listener-strategy = { version = "0.5" } flate2 = { version = "1", features = ["zlib-rs"] } -futures-lite = { version = "2" } +futures-io = { version = "0.3" } getrandom-9fbad63c4bcf4a8f = { package = "getrandom", version = "0.4", default-features = false, features = ["std", "sys_rng"] } half = { version = "2", default-features = false, features = ["num-traits"] } hyper-rustls = { version = "0.27", default-features = false, features = ["aws-lc-rs", "http1", "http2", "tls12"] } @@ -350,6 +347,7 @@ uuid = { version = "1", default-features = false, features = ["serde"] } zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } [target.aarch64-unknown-linux-gnu.build-dependencies] +bitflags = { version = "2", default-features = false, features = ["std"] } bytemuck = { version = "1", default-features = false, features = ["aarch64_simd"] } clang-sys = { version = "1", default-features = false, features = ["clang_11_0", "runtime"] } crossbeam-epoch = { version = "0.9" } @@ -357,7 +355,7 @@ cudarc = { version = "0.19", default-features = false, features = ["cuda-12020", event-listener = { version = "5" } event-listener-strategy = { version = "0.5" } flate2 = { version = "1", features = ["zlib-rs"] } -futures-lite = { version = "2" } +futures-io = { version = "0.3" } getrandom-9fbad63c4bcf4a8f = { package = "getrandom", version = "0.4", default-features = false, features = ["std", "sys_rng"] } half = { version = "2", default-features = false, features = ["num-traits"] } hyper-rustls = { version = "0.27", default-features = false, features = ["aws-lc-rs", "http1", "http2", "tls12"] } @@ -396,7 +394,7 @@ winapi = { version = "0.3", default-features = false, features = ["cfg", "evntra windows = { version = "0.62", features = ["Foundation_Metadata", "Graphics_Capture", "Graphics_DirectX_Direct3D11", "Security_Authorization_AppCapabilityAccess", "Wdk_System_SystemInformation", "Wdk_System_SystemServices", "Wdk_System_Threading", "Win32_Devices_DeviceAndDriverInstallation", "Win32_Devices_Display", "Win32_Devices_Properties", "Win32_Graphics_Direct3D10", "Win32_Graphics_Direct3D11", "Win32_Graphics_Direct3D12", "Win32_Graphics_Direct3D_Dxc", "Win32_Graphics_Direct3D_Fxc", "Win32_Graphics_DirectComposition", "Win32_Graphics_Dwm", "Win32_Graphics_Dxgi_Common", "Win32_Graphics_Gdi", "Win32_Graphics_OpenGL", "Win32_Media_Audio", "Win32_Media_KernelStreaming", "Win32_Media_MediaFoundation", "Win32_Media_Multimedia", "Win32_Security_Authorization", "Win32_System_Com_StructuredStorage", "Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Performance", "Win32_System_Power", "Win32_System_ProcessStatus", "Win32_System_Registry", "Win32_System_RemoteDesktop", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Variant", "Win32_System_WinRT_Direct3D11", "Win32_System_WinRT_Graphics_Capture", "Win32_UI_HiDpi", "Win32_UI_Shell_PropertiesSystem", "Win32_UI_WindowsAndMessaging"] } windows-core = { version = "0.62" } windows-sys-b21d60becc0929df = { package = "windows-sys", version = "0.52", features = ["Win32_Devices_HumanInterfaceDevice", "Win32_Foundation", "Win32_Globalization", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Media", "Win32_Networking_WinSock", "Win32_Security", "Win32_System_Com_StructuredStorage", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Ole", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Accessibility", "Win32_UI_Controls", "Win32_UI_HiDpi", "Win32_UI_Input_Ime", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_Input_Touch", "Win32_UI_Shell", "Win32_UI_TextServices", "Win32_UI_WindowsAndMessaging"] } -windows-sys-d4189bed749088b6 = { package = "windows-sys", version = "0.61", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_IO", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } +windows-sys-d4189bed749088b6 = { package = "windows-sys", version = "0.61", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_IO", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } [target.x86_64-pc-windows-msvc.build-dependencies] @@ -414,17 +412,18 @@ winapi = { version = "0.3", default-features = false, features = ["cfg", "evntra windows = { version = "0.62", features = ["Foundation_Metadata", "Graphics_Capture", "Graphics_DirectX_Direct3D11", "Security_Authorization_AppCapabilityAccess", "Wdk_System_SystemInformation", "Wdk_System_SystemServices", "Wdk_System_Threading", "Win32_Devices_DeviceAndDriverInstallation", "Win32_Devices_Display", "Win32_Devices_Properties", "Win32_Graphics_Direct3D10", "Win32_Graphics_Direct3D11", "Win32_Graphics_Direct3D12", "Win32_Graphics_Direct3D_Dxc", "Win32_Graphics_Direct3D_Fxc", "Win32_Graphics_DirectComposition", "Win32_Graphics_Dwm", "Win32_Graphics_Dxgi_Common", "Win32_Graphics_Gdi", "Win32_Graphics_OpenGL", "Win32_Media_Audio", "Win32_Media_KernelStreaming", "Win32_Media_MediaFoundation", "Win32_Media_Multimedia", "Win32_Security_Authorization", "Win32_System_Com_StructuredStorage", "Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Performance", "Win32_System_Power", "Win32_System_ProcessStatus", "Win32_System_Registry", "Win32_System_RemoteDesktop", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Variant", "Win32_System_WinRT_Direct3D11", "Win32_System_WinRT_Graphics_Capture", "Win32_UI_HiDpi", "Win32_UI_Shell_PropertiesSystem", "Win32_UI_WindowsAndMessaging"] } windows-core = { version = "0.62" } windows-sys-b21d60becc0929df = { package = "windows-sys", version = "0.52", features = ["Win32_Devices_HumanInterfaceDevice", "Win32_Foundation", "Win32_Globalization", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Media", "Win32_Networking_WinSock", "Win32_Security", "Win32_System_Com_StructuredStorage", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Ole", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Accessibility", "Win32_UI_Controls", "Win32_UI_HiDpi", "Win32_UI_Input_Ime", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_Input_Touch", "Win32_UI_Shell", "Win32_UI_TextServices", "Win32_UI_WindowsAndMessaging"] } -windows-sys-d4189bed749088b6 = { package = "windows-sys", version = "0.61", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_IO", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } +windows-sys-d4189bed749088b6 = { package = "windows-sys", version = "0.61", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_IO", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } [target.x86_64-unknown-linux-gnu.dependencies] +bitflags = { version = "2", default-features = false, features = ["std"] } bytemuck = { version = "1", default-features = false, features = ["aarch64_simd"] } crossbeam-epoch = { version = "0.9" } cudarc = { version = "0.19", default-features = false, features = ["cuda-12020", "driver", "fallback-dynamic-loading", "nvrtc"] } event-listener = { version = "5" } event-listener-strategy = { version = "0.5" } flate2 = { version = "1", features = ["zlib-rs"] } -futures-lite = { version = "2" } +futures-io = { version = "0.3" } getrandom-9fbad63c4bcf4a8f = { package = "getrandom", version = "0.4", default-features = false, features = ["std", "sys_rng"] } half = { version = "2", default-features = false, features = ["num-traits"] } hyper-rustls = { version = "0.27", default-features = false, features = ["aws-lc-rs", "http1", "http2", "tls12"] } @@ -452,6 +451,7 @@ uuid = { version = "1", default-features = false, features = ["serde"] } zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } [target.x86_64-unknown-linux-gnu.build-dependencies] +bitflags = { version = "2", default-features = false, features = ["std"] } bytemuck = { version = "1", default-features = false, features = ["aarch64_simd"] } clang-sys = { version = "1", default-features = false, features = ["clang_11_0", "runtime"] } crossbeam-epoch = { version = "0.9" } @@ -459,7 +459,7 @@ cudarc = { version = "0.19", default-features = false, features = ["cuda-12020", event-listener = { version = "5" } event-listener-strategy = { version = "0.5" } flate2 = { version = "1", features = ["zlib-rs"] } -futures-lite = { version = "2" } +futures-io = { version = "0.3" } getrandom-9fbad63c4bcf4a8f = { package = "getrandom", version = "0.4", default-features = false, features = ["std", "sys_rng"] } half = { version = "2", default-features = false, features = ["num-traits"] } hyper-rustls = { version = "0.27", default-features = false, features = ["aws-lc-rs", "http1", "http2", "tls12"] }