From 013f2d2bc0af03e19f45988d7fbf2d9a87c76205 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 25 Sep 2026 04:22:59 +0000 Subject: [PATCH 1/7] Hosted DNS service: reserve, records, lease and release Axum service over Route 53 and Postgres. Anonymous mint returns a name and a bcrypt-hashed bearer token; rotate, full-set apex records, lease renewal and release use that token. A reaper frees unused reservations after 24 hours and retires expired leases. Released names are never reissued. HTTP API tests run against a fake Route 53 and a Postgres testcontainer. --- .env.example | 15 + Cargo.lock | 4287 +++++++++++++++++++++++++++++++++++ Cargo.toml | 30 + README.md | 6 + migrations/0001_domains.sql | 10 + src/api.rs | 474 ++++ src/label.rs | 65 + src/lib.rs | 14 + src/main.rs | 64 + src/route53.rs | 116 + src/zone.rs | 51 + tests/api.rs | 450 ++++ 12 files changed, 5582 insertions(+) create mode 100644 .env.example create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 migrations/0001_domains.sql create mode 100644 src/api.rs create mode 100644 src/label.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/route53.rs create mode 100644 src/zone.rs create mode 100644 tests/api.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..826131e --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Postgres (PlanetScale in production). Include sslmode=require for hosted databases. +DATABASE_URL=postgres://user:password@localhost:5432/hosted_dns +# Route 53 hosted zone for the apex, and the apex itself. +HOSTED_ZONE_ID=Z0000000000000000000 +APEX_DOMAIN=example.com +# Standard AWS credentials for an IAM user limited to that zone. +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION=us-east-1 +# Optional. +PORT=8080 +MINTS_PER_HOUR=30 +# Set when behind a proxy that overwrites this header with the client IP. +CLIENT_IP_HEADER=x-real-ip +RUST_LOG=info diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2cb8753 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4287 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "astral-tokio-tar" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463" +dependencies = [ + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "rustix", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-config" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d7b388a9fc3a6db15a5ec778c38b354eff1364882c94d08e0252f7a47dcaa4" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.5.0", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "aws-runtime" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.5.0", + "http-body 1.1.0", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-route53" +version = "1.125.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4412566fe23351d03d09e6f4ac7e3941d8d67cdd82b6e23c0a1e030173641ec5" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 1.5.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.112.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d59c47556b283bb2d20fc0b416df7d9d9a3d6f5fa0e77867167a3f2efc23b9" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 1.5.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eceb9f31b46ba6da21f666e456395624824a2a086caed940b13d78832fa3ee02" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 1.5.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.117.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9291df9ea767ab5e77d08a53f4f22daa917909a34ed7a22c1a4b82d193a423ad" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 1.5.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 1.5.0", + "percent-encoding", + "sha2 0.11.0", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.64.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd25384a4e437aa8d8f339afad4b69e786b936a7cb10db668a7aaf66717b1a8" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.19", + "http 0.2.12", + "http 1.5.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.11.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.10", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.45", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.5", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.63.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3385d469edbe8b60cc72002784652b5efca39178192aa9cc4b44c9875c6bdc18" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d1d71f6562be974caa85442ecd90194c40fdb5df045f182a6c2e872ce95056" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591e0024cdf2a8de8711860f7e1d5cfbca26b800ce13793a9aa2f4f5918e6d95" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb0deb40a69de59f7809251cfe1ab16de135f42c49d222d1202b4d0ce19720f" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.5.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.5.0", +] + +[[package]] +name = "aws-smithy-types" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.5.0", + "http-body 0.4.6", + "http-body 1.1.0", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b932c8d6dc127fc980eecd78f8694ae9b9551b69a93a7def2a199c1c0033daf" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper 1.11.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a" +dependencies = [ + "base64 0.23.1", + "blowfish", + "getrandom 0.4.3", + "subtle", + "zeroize", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bollard" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags 2.13.2", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http 1.5.0", + "http-body-util", + "hyper 1.11.1", + "hyper-named-pipe", + "hyper-rustls 0.27.10", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.9.5", + "rustls 0.23.45", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", + "ureq", +] + +[[package]] +name = "bollard-stubs" +version = "1.52.1-rc.29.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "prost", + "serde", + "serde_json", + "serde_repr", + "time", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ferroid" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" +dependencies = [ + "portable-atomic", + "rand 0.10.3", + "web-time", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.5.0", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hosted-dns" +version = "0.1.0" +dependencies = [ + "aws-config", + "aws-sdk-route53", + "axum", + "bcrypt", + "chrono", + "http-body-util", + "rand 0.9.5", + "serde", + "serde_json", + "sqlx", + "testcontainers-modules", + "thiserror", + "tokio", + "tower", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.5.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.19", + "http 1.5.0", + "http-body 1.1.0", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-named-pipe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" +dependencies = [ + "hex", + "hyper 1.11.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http 1.5.0", + "hyper 1.11.1", + "hyper-util", + "rustls 0.23.45", + "rustls-native-certs", + "tokio", + "tokio-rustls 0.26.5", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper 1.11.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "httparse", + "hyper 1.11.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper 1.11.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn 2.0.119", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.15", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.14.2", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls 0.23.45", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.2", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac 0.12.1", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.8", + "rsa", + "serde", + "sha1", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.2", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera 0.8.0", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac 0.12.1", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.8", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.119", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "testcontainers" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera 0.11.0", + "ferroid", + "futures", + "http 1.5.0", + "itertools", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "testcontainers-modules" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5985fde5befe4ffa77a052e035e16c2da86e8bae301baa9f9904ad3c494d357" +dependencies = [ + "testcontainers", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls 0.23.45", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2 0.4.19", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper 1.11.1", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2 0.6.5", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 2.14.2", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "rustls 0.23.45", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http 1.5.0", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[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.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b3666c0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "hosted-dns" +version = "0.1.0" +edition = "2024" + +[dependencies] +aws-config = { version = "1.12.0", features = ["behavior-version-latest"] } +aws-sdk-route53 = "1.125.0" +axum = "0.8.9" +bcrypt = "0.19.3" +chrono = { version = "0.4.45", features = ["serde"] } +rand = "0.9" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "migrate", "macros"] } +thiserror = "2.0.21" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } + +[dev-dependencies] +http-body-util = "0.1.5" +testcontainers-modules = { version = "0.15.0", features = ["postgres"] } +tower = { version = "0.5.3", features = ["util"] } + +# bcrypt is unusably slow unoptimised; keep tests fast. +[profile.dev.package.bcrypt] +opt-level = 3 +[profile.dev.package.blowfish] +opt-level = 3 diff --git a/README.md b/README.md index 9bb3aba..3bbe609 100644 --- a/README.md +++ b/README.md @@ -1 +1,7 @@ # hosted-dns — Ployz Hosted DNS service: generated Cluster Domains on Route 53. + +Rust axum service backed by Route 53 and Postgres. Configuration comes from the +environment; see `.env.example`. Migrations run at startup. + +Tests need Docker: `cargo test` starts a throwaway Postgres per test +(testcontainers) and runs the HTTP API against an in-memory fake Route 53. diff --git a/migrations/0001_domains.sql b/migrations/0001_domains.sql new file mode 100644 index 0000000..6b87e3a --- /dev/null +++ b/migrations/0001_domains.sql @@ -0,0 +1,10 @@ +-- One row per Cluster Domain ever granted. Released names keep their row +-- (retired_at set) so the primary key stops them being reissued. +CREATE TABLE domains ( + name TEXT PRIMARY KEY, + token_hash TEXT NOT NULL, + reserved_at TIMESTAMPTZ NOT NULL DEFAULT now(), + lease_expires_at TIMESTAMPTZ NOT NULL, + has_records BOOLEAN NOT NULL DEFAULT false, + retired_at TIMESTAMPTZ +); diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..e1dc932 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,474 @@ +//! HTTP API: mint, rotate, records, lease, release, and the reaper. + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use axum::extract::rejection::JsonRejection; +use axum::extract::{ConnectInfo, Path, State}; +use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post, put}; +use axum::{Json, Router}; +use chrono::{DateTime, TimeDelta, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{PgConnection, PgPool}; + +use crate::label; +use crate::zone::{Change, RecordSet, RecordType, Zone, ZoneError}; + +const LEASE: TimeDelta = TimeDelta::days(7); +const UNUSED_RESERVATION: TimeDelta = TimeDelta::hours(24); +const TOKEN_LEN: usize = 40; +// Tokens are 200+ random bits, so bcrypt's work factor guards nothing brute force could reach. +const BCRYPT_COST: u32 = 10; +const MINT_ATTEMPTS: usize = 8; +const RECORD_TTL: i64 = 60; +const MAX_ADDRESSES: usize = 32; +const MINT_WINDOW: Duration = Duration::from_secs(3600); + +/// Service settings read from the environment at startup. +pub struct Config { + /// Zone apex every Cluster Domain sits under, e.g. `ployz.app`. + pub apex: String, + /// Mints allowed per source IP (IPv6: per /64) per hour. + pub mints_per_hour: u32, + /// Header holding the client IP when behind a proxy; the peer address otherwise. + pub client_ip_header: Option, +} + +/// Shared state for every request. +pub struct AppState { + db: PgPool, + zone: Z, + config: Config, + mints: MintLimiter, +} + +impl AppState { + pub fn new(db: PgPool, zone: Z, config: Config) -> Self { + let mints = MintLimiter { + per_hour: config.mints_per_hour, + windows: Mutex::default(), + }; + Self { + db, + zone, + config, + mints, + } + } +} + +/// The service's routes. +pub fn router(state: Arc>) -> Router { + Router::new() + .route("/healthz", get(|| async { "ok" })) + .route("/domains", post(mint::)) + .route("/domains/{name}", delete(release::)) + .route("/domains/{name}/rotate", post(rotate::)) + .route("/domains/{name}/lease", post(renew::)) + .route("/domains/{name}/records", put(put_records::)) + .with_state(state) +} + +#[derive(Deserialize)] +struct MintRequest { + preferred: Option, +} + +#[derive(Serialize)] +struct MintResponse { + name: String, + token: String, +} + +async fn mint( + State(state): State>>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + body: Result>, JsonRejection>, +) -> Result<(StatusCode, Json), ApiError> { + let body = body.map_err(|rejection| ApiError::BadRequest(rejection.body_text()))?; + let client = client_ip(&headers, state.config.client_ip_header.as_ref(), peer.ip()); + state.mints.check(client)?; + let preferred = body.and_then(|Json(body)| body.preferred); + let token = label::random_string(TOKEN_LEN); + let token_hash = hash(&token).await?; + for label in label::candidates(preferred.as_deref()).take(MINT_ATTEMPTS) { + let name = format!("{label}.{}", state.config.apex); + // Retired names keep their row, so the conflict also refuses them. + let inserted = sqlx::query( + "INSERT INTO domains (name, token_hash, lease_expires_at) VALUES ($1, $2, now() + $3) + ON CONFLICT (name) DO NOTHING", + ) + .bind(&name) + .bind(&token_hash) + .bind(LEASE) + .execute(&state.db) + .await? + .rows_affected() + == 1; + if inserted { + return Ok((StatusCode::CREATED, Json(MintResponse { name, token }))); + } + } + Err(ApiError::NamespaceExhausted) +} + +#[derive(Serialize)] +struct RotateResponse { + token: String, +} + +async fn rotate( + State(state): State>>, + Path(name): Path, + headers: HeaderMap, +) -> Result, ApiError> { + let mut tx = state.db.begin().await?; + authenticate(&mut tx, &name, &headers).await?; + let token = label::random_string(TOKEN_LEN); + sqlx::query("UPDATE domains SET token_hash = $2 WHERE name = $1") + .bind(&name) + .bind(hash(&token).await?) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(RotateResponse { token })) +} + +#[derive(Serialize)] +struct LeaseResponse { + name: String, + lease_expires_at: DateTime, +} + +async fn renew( + State(state): State>>, + Path(name): Path, + headers: HeaderMap, +) -> Result, ApiError> { + let mut tx = state.db.begin().await?; + let lease_expires_at = authenticate(&mut tx, &name, &headers).await?; + tx.commit().await?; + Ok(Json(LeaseResponse { + name, + lease_expires_at, + })) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Records { + #[serde(default)] + a: Vec, + #[serde(default)] + aaaa: Vec, +} + +async fn put_records( + State(state): State>>, + Path(name): Path, + headers: HeaderMap, + body: Result, JsonRejection>, +) -> Result { + let Json(records) = body.map_err(|rejection| ApiError::BadRequest(rejection.body_text()))?; + let mut tx = state.db.begin().await?; + authenticate(&mut tx, &name, &headers).await?; + let wanted = [ + (RecordType::A, addresses(records.a, |ip| is_public_v4(*ip))?), + (RecordType::Aaaa, addresses(records.aaaa, is_public_v6)?), + ]; + if wanted.iter().all(|(_, values)| values.is_empty()) { + return Err(ApiError::InvalidRecords( + "at least one address is required".into(), + )); + } + + let current = state.zone.record_sets(&name).await?; + let mut changes = Vec::new(); + for (kind, values) in wanted { + if !values.is_empty() { + changes.push(Change::Upsert(RecordSet { + name: name.clone(), + kind, + ttl: RECORD_TTL, + values, + })); + } else if let Some(stale) = current.iter().find(|set| set.kind == kind) { + changes.push(Change::Delete(stale.clone())); + } + } + let wildcard = format!("*.{name}"); + let has_wildcard = state + .zone + .record_sets(&wildcard) + .await? + .iter() + .any(|set| set.kind == RecordType::Cname); + if !has_wildcard { + changes.push(Change::Upsert(RecordSet { + values: vec![name.clone()], + name: wildcard, + kind: RecordType::Cname, + ttl: RECORD_TTL, + })); + } + state.zone.apply(changes).await?; + + sqlx::query("UPDATE domains SET has_records = true WHERE name = $1") + .bind(&name) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn release( + State(state): State>>, + Path(name): Path, + headers: HeaderMap, +) -> Result { + let mut tx = state.db.begin().await?; + authenticate(&mut tx, &name, &headers).await?; + retire(&state.zone, &mut tx, &name).await?; + tx.commit().await?; + Ok(StatusCode::NO_CONTENT) +} + +/// Frees reservations that published no records within 24 hours, and retires +/// names whose lease expired (removing their records). +/// +/// # Errors +/// +/// Returns [`ApiError`] on the first database or Route 53 failure; the next run retries. +pub async fn reap(state: &AppState) -> Result<(), ApiError> { + sqlx::query( + "DELETE FROM domains + WHERE retired_at IS NULL AND NOT has_records AND reserved_at < now() - $1", + ) + .bind(UNUSED_RESERVATION) + .execute(&state.db) + .await?; + // ponytail: one name per transaction; a name Route 53 keeps refusing stalls the rest until fixed. + loop { + let mut tx = state.db.begin().await?; + let Some(name) = sqlx::query_scalar::<_, String>( + "SELECT name FROM domains WHERE retired_at IS NULL AND lease_expires_at < now() + LIMIT 1 FOR UPDATE SKIP LOCKED", + ) + .fetch_optional(&mut *tx) + .await? + else { + return Ok(()); + }; + retire(&state.zone, &mut tx, &name).await?; + tx.commit().await?; + tracing::info!(%name, "retired expired lease"); + } +} + +/// Removes the apex and wildcard records and marks the name retired forever. +async fn retire(zone: &Z, tx: &mut PgConnection, name: &str) -> Result<(), ApiError> { + let mut changes = Vec::new(); + for set_name in [name.to_owned(), format!("*.{name}")] { + changes.extend( + zone.record_sets(&set_name) + .await? + .into_iter() + .map(Change::Delete), + ); + } + if !changes.is_empty() { + zone.apply(changes).await?; + } + sqlx::query("UPDATE domains SET retired_at = now() WHERE name = $1") + .bind(name) + .execute(tx) + .await?; + Ok(()) +} + +/// Locks the name's row for the transaction, checks the bearer token and renews +/// the lease. Returns the new lease expiry. +async fn authenticate( + tx: &mut PgConnection, + name: &str, + headers: &HeaderMap, +) -> Result, ApiError> { + let token = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .ok_or(ApiError::Unauthorized)? + .to_owned(); + let row: Option<(String, Option>)> = + sqlx::query_as("SELECT token_hash, retired_at FROM domains WHERE name = $1 FOR UPDATE") + .bind(name) + .fetch_optional(&mut *tx) + .await?; + let Some((token_hash, retired_at)) = row else { + return Err(ApiError::NotFound); + }; + if retired_at.is_some() { + return Err(ApiError::Retired); + } + let valid = tokio::task::spawn_blocking(move || bcrypt::verify(token, &token_hash)) + .await + .expect("bcrypt verify does not panic")?; + if !valid { + return Err(ApiError::Unauthorized); + } + let lease_expires_at = sqlx::query_scalar( + "UPDATE domains SET lease_expires_at = now() + $2 WHERE name = $1 + RETURNING lease_expires_at", + ) + .bind(name) + .bind(LEASE) + .fetch_one(&mut *tx) + .await?; + Ok(lease_expires_at) +} + +async fn hash(token: &str) -> Result { + let token = token.to_owned(); + Ok( + tokio::task::spawn_blocking(move || bcrypt::hash(token, BCRYPT_COST)) + .await + .expect("bcrypt hash does not panic")?, + ) +} + +/// Sorts and dedups `addresses`, refusing non-public ones and oversized sets. +fn addresses( + mut addresses: Vec, + is_public: impl Fn(&T) -> bool, +) -> Result, ApiError> { + addresses.sort(); + addresses.dedup(); + if addresses.len() > MAX_ADDRESSES { + return Err(ApiError::InvalidRecords(format!( + "at most {MAX_ADDRESSES} addresses per family" + ))); + } + if let Some(bad) = addresses.iter().find(|ip| !is_public(ip)) { + return Err(ApiError::InvalidRecords(format!( + "{} is not a public address", + bad.to_string() + ))); + } + Ok(addresses.iter().map(ToString::to_string).collect()) +} + +fn is_public_v4(ip: Ipv4Addr) -> bool { + !(ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_multicast() + || ip.is_broadcast()) +} + +fn is_public_v6(ip: &Ipv6Addr) -> bool { + !(ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || ip.is_unique_local() + || ip.is_unicast_link_local() + || ip.to_ipv4_mapped().is_some()) +} + +fn client_ip(headers: &HeaderMap, header: Option<&HeaderName>, peer: IpAddr) -> IpAddr { + header + .and_then(|header| headers.get(header)) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(peer) +} + +/// Fixed-window mint counter per source address. +// ponytail: in memory and per instance; move to Postgres if the service ever runs replicas. +struct MintLimiter { + per_hour: u32, + windows: Mutex>, +} + +impl MintLimiter { + fn check(&self, ip: IpAddr) -> Result<(), ApiError> { + // One IPv6 user trivially holds a /64, so count the /64. + let key = match ip { + IpAddr::V6(v6) => IpAddr::V6(Ipv6Addr::from(u128::from(v6) & !u128::from(u64::MAX))), + IpAddr::V4(_) => ip, + }; + let now = Instant::now(); + let mut windows = self + .windows + .lock() + .expect("mint limiter lock is never poisoned"); + windows.retain(|_, (start, _)| now.duration_since(*start) < MINT_WINDOW); + let (start, count) = windows.entry(key).or_insert((now, 0)); + if *count >= self.per_hour { + let retry_after_secs = (MINT_WINDOW - now.duration_since(*start)).as_secs().max(1); + return Err(ApiError::RateLimited { retry_after_secs }); + } + *count += 1; + Ok(()) + } +} + +/// Every failure the API returns, rendered as `{"error": code, "message": text}`. +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[error("{0}")] + BadRequest(String), + #[error("{0}")] + InvalidRecords(String), + #[error("missing or wrong bearer token")] + Unauthorized, + #[error("no such domain")] + NotFound, + #[error("domain was released and is retired")] + Retired, + #[error("too many domains minted from this address")] + RateLimited { retry_after_secs: u64 }, + #[error("no free label found, try again")] + NamespaceExhausted, + #[error("dns provider: {0}")] + Zone(#[from] ZoneError), + #[error("database: {0}")] + Db(#[from] sqlx::Error), + #[error("token hashing: {0}")] + Hash(#[from] bcrypt::BcryptError), +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, code) = match &self { + Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"), + Self::InvalidRecords(_) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_records"), + Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"), + Self::NotFound => (StatusCode::NOT_FOUND, "not_found"), + Self::Retired => (StatusCode::GONE, "retired"), + Self::RateLimited { .. } => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"), + Self::NamespaceExhausted => (StatusCode::SERVICE_UNAVAILABLE, "namespace_exhausted"), + Self::Zone(_) => (StatusCode::BAD_GATEWAY, "dns_provider_error"), + Self::Db(_) | Self::Hash(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal"), + }; + let message = if status.is_server_error() { + tracing::error!(error = %self, "request failed"); + "internal error".to_owned() + } else { + self.to_string() + }; + let body = Json(serde_json::json!({ "error": code, "message": message })); + let mut response = (status, body).into_response(); + if let Self::RateLimited { retry_after_secs } = self { + response + .headers_mut() + .insert(header::RETRY_AFTER, HeaderValue::from(retry_after_secs)); + } + response + } +} diff --git a/src/label.rs b/src/label.rs new file mode 100644 index 0000000..97691cb --- /dev/null +++ b/src/label.rs @@ -0,0 +1,65 @@ +//! Label choice for new Cluster Domains. + +use rand::Rng; + +/// Labels that are never granted verbatim; they get a suffix instead. +const RESERVED: &[&str] = &[ + "www", + "api", + "dns", + "mail", + "admin", + "cloud", + "app", + "ployz", + "ns", + "ns1", + "ns2", + "smtp", + "imap", + "status", + "docs", + "dashboard", + "support", + "root", + "autoconfig", + "autodiscover", +]; + +const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; +const SUFFIX_LEN: usize = 4; +const RANDOM_LABEL_LEN: usize = 10; + +/// Labels to try, best first: the preferred label if valid and not reserved, +/// then endless `preferred-xxxx` candidates, or endless random labels when the +/// preferred label is missing or invalid. +pub(crate) fn candidates(preferred: Option<&str>) -> impl Iterator + '_ { + let base = preferred.filter(|label| is_valid(label)); + let exact = base + .filter(|label| !RESERVED.contains(label)) + .map(str::to_owned); + exact + .into_iter() + .chain(std::iter::repeat_with(move || match base { + Some(base) => format!("{base}-{}", random_string(SUFFIX_LEN)), + None => random_string(RANDOM_LABEL_LEN), + })) +} + +/// `[a-z0-9-]{3,40}` with no hyphen at either edge. +fn is_valid(label: &str) -> bool { + (3..=40).contains(&label.len()) + && label + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && !label.starts_with('-') + && !label.ends_with('-') +} + +/// Random `[a-z0-9]` string; also used for bearer tokens. +pub(crate) fn random_string(len: usize) -> String { + let mut rng = rand::rng(); + (0..len) + .map(|_| char::from(ALPHABET[rng.random_range(0..ALPHABET.len())])) + .collect() +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..0985775 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,14 @@ +//! Hosted DNS: mints generated Cluster Domains under one Route 53 zone and +//! publishes their apex addresses for Ployz Cloud. + +mod api; +mod label; +mod route53; +mod zone; + +pub use api::{ApiError, AppState, Config, reap, router}; +pub use route53::Route53; +pub use zone::{Change, RecordSet, RecordType, Zone, ZoneError}; + +/// Schema migrations, run at startup. +pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!(); diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c2fce0d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,64 @@ +//! Hosted DNS server. Configuration comes from the environment; see `.env.example`. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use hosted_dns::{AppState, Config, MIGRATOR, Route53, reap, router}; +use sqlx::postgres::PgPoolOptions; +use tracing_subscriber::EnvFilter; + +const REAP_EVERY: Duration = Duration::from_secs(300); + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .init(); + + let db = PgPoolOptions::new() + .max_connections(5) + .connect(&required("DATABASE_URL")?) + .await?; + MIGRATOR.run(&db).await?; + let aws = aws_config::load_from_env().await; + let zone = Route53::new(&aws, required("HOSTED_ZONE_ID")?); + let config = Config { + apex: required("APEX_DOMAIN")?, + mints_per_hour: optional("MINTS_PER_HOUR")?.unwrap_or(30), + client_ip_header: optional("CLIENT_IP_HEADER")?, + }; + let state = Arc::new(AppState::new(db, zone, config)); + + let reaper = Arc::clone(&state); + tokio::spawn(async move { + let mut tick = tokio::time::interval(REAP_EVERY); + loop { + tick.tick().await; + if let Err(error) = reap(&reaper).await { + tracing::error!(%error, "reap failed"); + } + } + }); + + let port: u16 = optional("PORT")?.unwrap_or(8080); + let listener = tokio::net::TcpListener::bind(("::", port)).await?; + tracing::info!(port, "listening"); + axum::serve( + listener, + router(state).into_make_service_with_connect_info::(), + ) + .await?; + Ok(()) +} + +fn required(key: &str) -> Result { + std::env::var(key).map_err(|_| format!("{key} is not set")) +} + +fn optional(key: &str) -> Result, String> { + std::env::var(key) + .ok() + .map(|value| value.parse().map_err(|_| format!("{key} is invalid"))) + .transpose() +} diff --git a/src/route53.rs b/src/route53.rs new file mode 100644 index 0000000..15ba206 --- /dev/null +++ b/src/route53.rs @@ -0,0 +1,116 @@ +//! [`Zone`] backed by one Route 53 hosted zone. + +use aws_sdk_route53::error::DisplayErrorContext; +use aws_sdk_route53::types as r53; + +use crate::zone::{Change, RecordSet, RecordType, Zone, ZoneError}; + +/// The production hosted zone. +pub struct Route53 { + client: aws_sdk_route53::Client, + zone_id: String, +} + +impl Route53 { + /// Uses credentials and region from the standard AWS environment. + pub fn new(config: &aws_config::SdkConfig, zone_id: String) -> Self { + Self { + client: aws_sdk_route53::Client::new(config), + zone_id, + } + } +} + +impl Zone for Route53 { + async fn record_sets(&self, name: &str) -> Result, ZoneError> { + // Listing starts at `name`; its own sets sort first, so one small page is enough. + let page = self + .client + .list_resource_record_sets() + .hosted_zone_id(&self.zone_id) + .start_record_name(name) + .max_items(10) + .send() + .await + .map_err(zone_error)?; + Ok(page + .resource_record_sets() + .iter() + .filter(|set| normalize(set.name()) == name) + .filter_map(|set| { + let kind = match set.r#type() { + r53::RrType::A => RecordType::A, + r53::RrType::Aaaa => RecordType::Aaaa, + r53::RrType::Cname => RecordType::Cname, + _ => return None, + }; + Some(RecordSet { + name: name.to_owned(), + kind, + ttl: set.ttl().unwrap_or_default(), + values: set + .resource_records() + .iter() + .map(|record| record.value().to_owned()) + .collect(), + }) + }) + .collect()) + } + + async fn apply(&self, changes: Vec) -> Result<(), ZoneError> { + let changes = changes + .into_iter() + .map(to_r53) + .collect::, _>>() + .map_err(zone_error)?; + let batch = r53::ChangeBatch::builder() + .set_changes(Some(changes)) + .build() + .map_err(zone_error)?; + self.client + .change_resource_record_sets() + .hosted_zone_id(&self.zone_id) + .change_batch(batch) + .send() + .await + .map_err(zone_error)?; + Ok(()) + } +} + +fn to_r53(change: Change) -> Result { + let (action, set) = match change { + Change::Upsert(set) => (r53::ChangeAction::Upsert, set), + Change::Delete(set) => (r53::ChangeAction::Delete, set), + }; + let records = set + .values + .iter() + .map(|value| r53::ResourceRecord::builder().value(value).build()) + .collect::, _>>()?; + let kind = match set.kind { + RecordType::A => r53::RrType::A, + RecordType::Aaaa => r53::RrType::Aaaa, + RecordType::Cname => r53::RrType::Cname, + }; + let set = r53::ResourceRecordSet::builder() + .name(set.name) + .r#type(kind) + .ttl(set.ttl) + .set_resource_records(Some(records)) + .build()?; + r53::Change::builder() + .action(action) + .resource_record_set(set) + .build() +} + +/// Route 53 returns `\052.acme.ployz.app.` for `*.acme.ployz.app`. +fn normalize(name: &str) -> String { + name.trim_end_matches('.').replace("\\052", "*") +} + +fn zone_error(error: impl std::error::Error) -> ZoneError { + ZoneError(DisplayErrorContext(error).to_string()) +} diff --git a/src/zone.rs b/src/zone.rs new file mode 100644 index 0000000..55b4267 --- /dev/null +++ b/src/zone.rs @@ -0,0 +1,51 @@ +//! The DNS zone seam: Route 53 in production, a fake in tests. + +/// Record types the service manages. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RecordType { + A, + Aaaa, + Cname, +} + +/// One Route 53 resource record set. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RecordSet { + /// Fully qualified name without the trailing dot, e.g. `*.acme.ployz.app`. + pub name: String, + pub kind: RecordType, + pub ttl: i64, + pub values: Vec, +} + +/// A change with Route 53 semantics: `Delete` must match the existing set exactly. +#[derive(Clone, Debug)] +pub enum Change { + Upsert(RecordSet), + Delete(RecordSet), +} + +/// The DNS provider refused or failed a call. +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub struct ZoneError(pub String); + +/// The hosted zone that holds every Cluster Domain. +pub trait Zone: Send + Sync + 'static { + /// Lists the managed record sets whose name is exactly `name`. + /// + /// # Errors + /// + /// Returns [`ZoneError`] when the provider call fails. + fn record_sets( + &self, + name: &str, + ) -> impl Future, ZoneError>> + Send; + + /// Applies `changes` as one atomic batch. + /// + /// # Errors + /// + /// Returns [`ZoneError`] when the provider rejects or fails the batch. + fn apply(&self, changes: Vec) -> impl Future> + Send; +} diff --git a/tests/api.rs b/tests/api.rs new file mode 100644 index 0000000..a611aae --- /dev/null +++ b/tests/api.rs @@ -0,0 +1,450 @@ +//! HTTP API tests against a fake Route 53 and a throwaway Postgres container. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; + +use axum::Router; +use axum::body::Body; +use axum::extract::connect_info::MockConnectInfo; +use axum::http::{Method, Request, StatusCode}; +use hosted_dns::{ + AppState, Change, Config, MIGRATOR, RecordSet, RecordType, Zone, ZoneError, reap, router, +}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use sqlx::PgPool; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::ContainerAsync; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use tower::ServiceExt; + +const APEX: &str = "ployz.test"; + +/// Route 53 semantics in memory: batches are atomic and a delete must match exactly. +#[derive(Clone, Default)] +struct FakeZone(Arc>>); + +impl FakeZone { + fn values(&self, name: &str, kind: RecordType) -> Option> { + let sets = self.0.lock().unwrap(); + sets.get(&(name.to_owned(), kind)) + .map(|set| set.values.clone()) + } + + fn is_empty(&self) -> bool { + self.0.lock().unwrap().is_empty() + } +} + +impl Zone for FakeZone { + async fn record_sets(&self, name: &str) -> Result, ZoneError> { + let sets = self.0.lock().unwrap(); + Ok(sets + .values() + .filter(|set| set.name == name) + .cloned() + .collect()) + } + + async fn apply(&self, changes: Vec) -> Result<(), ZoneError> { + let mut sets = self.0.lock().unwrap(); + let mut next = sets.clone(); + for change in changes { + match change { + Change::Upsert(set) => { + next.insert((set.name.clone(), set.kind), set); + } + Change::Delete(set) => { + if next.remove(&(set.name.clone(), set.kind)).as_ref() != Some(&set) { + return Err(ZoneError(format!("delete of {} does not match", set.name))); + } + } + } + } + *sets = next; + Ok(()) + } +} + +struct Harness { + app: Router, + state: Arc>, + zone: FakeZone, + db: PgPool, + _postgres: ContainerAsync, +} + +async fn harness(mints_per_hour: u32) -> Harness { + let postgres = Postgres::default().start().await.unwrap(); + let url = format!( + "postgres://postgres:postgres@{}:{}/postgres", + postgres.get_host().await.unwrap(), + postgres.get_host_port_ipv4(5432).await.unwrap() + ); + let db = PgPool::connect(&url).await.unwrap(); + MIGRATOR.run(&db).await.unwrap(); + let zone = FakeZone::default(); + let config = Config { + apex: APEX.into(), + mints_per_hour, + client_ip_header: Some("x-real-ip".parse().unwrap()), + }; + let state = Arc::new(AppState::new(db.clone(), zone.clone(), config)); + let app = router(Arc::clone(&state)) + .layer(MockConnectInfo(SocketAddr::from(([198, 51, 100, 1], 4000)))); + Harness { + app, + state, + zone, + db, + _postgres: postgres, + } +} + +impl Harness { + async fn call( + &self, + method: Method, + uri: &str, + headers: &[(&str, &str)], + body: Option, + ) -> (StatusCode, Value) { + let mut request = Request::builder().method(method).uri(uri); + for (key, value) in headers { + request = request.header(*key, *value); + } + let body = match body { + Some(body) => { + request = request.header("content-type", "application/json"); + Body::from(body.to_string()) + } + None => Body::empty(), + }; + let response = self + .app + .clone() + .oneshot(request.body(body).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + (status, body) + } + + async fn mint(&self, preferred: Option<&str>) -> (String, String) { + let (status, body) = self + .call( + Method::POST, + "/domains", + &[], + Some(json!({ "preferred": preferred })), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + ( + body["name"].as_str().unwrap().to_owned(), + body["token"].as_str().unwrap().to_owned(), + ) + } + + async fn authed( + &self, + method: Method, + name: &str, + path: &str, + token: &str, + body: Option, + ) -> (StatusCode, Value) { + let bearer = format!("Bearer {token}"); + let uri = format!("/domains/{name}{path}"); + self.call(method, &uri, &[("authorization", &bearer)], body) + .await + } + + async fn put_records(&self, name: &str, token: &str, records: Value) -> StatusCode { + self.authed(Method::PUT, name, "/records", token, Some(records)) + .await + .0 + } +} + +fn label_of(name: &str) -> &str { + name.strip_suffix(&format!(".{APEX}")).unwrap() +} + +#[tokio::test] +async fn mint_grants_preferred_suffixed_or_random_labels() { + let h = harness(100).await; + + let (name, token) = h.mint(Some("acme")).await; + assert_eq!(name, format!("acme.{APEX}")); + assert_eq!(token.len(), 40); + + // Taken and reserved labels get a 4-character suffix. + let (taken, _) = h.mint(Some("acme")).await; + let suffix = label_of(&taken).strip_prefix("acme-").unwrap(); + assert_eq!(suffix.len(), 4); + let (reserved, _) = h.mint(Some("www")).await; + assert!(label_of(&reserved).starts_with("www-"), "{reserved}"); + + // Invalid, edge-hyphen and missing labels get a fully random label. + for preferred in [Some("ab"), Some("-acme"), Some("Acme"), None] { + let (random, _) = h.mint(preferred).await; + let label = label_of(&random); + assert_eq!(label.len(), 10, "{preferred:?} -> {random}"); + assert!(!label.contains('-'), "{random}"); + } + + // No body at all is a random label too. + let (status, body) = h.call(Method::POST, "/domains", &[], None).await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(label_of(body["name"].as_str().unwrap()).len(), 10); +} + +#[tokio::test] +async fn mint_is_rate_limited_per_source_ip() { + let h = harness(2).await; + for (ip, expected) in [ + ("203.0.113.7", StatusCode::CREATED), + ("203.0.113.7", StatusCode::CREATED), + ("203.0.113.7", StatusCode::TOO_MANY_REQUESTS), + ("203.0.113.8", StatusCode::CREATED), + ] { + let (status, body) = h + .call( + Method::POST, + "/domains", + &[("x-real-ip", ip)], + Some(json!({})), + ) + .await; + assert_eq!(status, expected, "{ip}: {body}"); + } +} + +#[tokio::test] +async fn rotate_kills_the_old_token_at_once() { + let h = harness(100).await; + let (name, old) = h.mint(Some("rotating")).await; + + let (status, body) = h.authed(Method::POST, &name, "/rotate", &old, None).await; + assert_eq!(status, StatusCode::OK); + let new = body["token"].as_str().unwrap().to_owned(); + assert_ne!(new, old); + + let (status, body) = h.authed(Method::POST, &name, "/lease", &old, None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["error"], "unauthorized"); + let (status, _) = h.authed(Method::POST, &name, "/lease", &new, None).await; + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn authentication_failures_are_distinguished() { + let h = harness(100).await; + let (name, _) = h.mint(Some("guarded")).await; + + let (status, _) = h + .call(Method::POST, &format!("/domains/{name}/lease"), &[], None) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let (status, _) = h.authed(Method::POST, &name, "/lease", "wrong", None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let (status, body) = h + .authed(Method::POST, "nope.ployz.test", "/lease", "wrong", None) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(body["error"], "not_found"); +} + +#[tokio::test] +async fn records_put_replaces_the_apex_set_and_writes_the_wildcard() { + let h = harness(100).await; + let (name, token) = h.mint(Some("records")).await; + let wildcard = format!("*.{name}"); + + let both = + json!({ "a": ["203.0.113.2", "203.0.113.1", "203.0.113.2"], "aaaa": ["2001:db8::1"] }); + assert_eq!( + h.put_records(&name, &token, both.clone()).await, + StatusCode::NO_CONTENT + ); + assert_eq!( + h.zone.values(&name, RecordType::A).unwrap(), + ["203.0.113.1", "203.0.113.2"] + ); + assert_eq!( + h.zone.values(&name, RecordType::Aaaa).unwrap(), + ["2001:db8::1"] + ); + assert_eq!( + h.zone.values(&wildcard, RecordType::Cname).unwrap(), + [name.as_str()] + ); + + // Idempotent. + assert_eq!( + h.put_records(&name, &token, both).await, + StatusCode::NO_CONTENT + ); + + // Full set: an omitted family is removed. + let v4_only = json!({ "a": ["198.51.100.9"] }); + assert_eq!( + h.put_records(&name, &token, v4_only).await, + StatusCode::NO_CONTENT + ); + assert_eq!( + h.zone.values(&name, RecordType::A).unwrap(), + ["198.51.100.9"] + ); + assert_eq!(h.zone.values(&name, RecordType::Aaaa), None); + assert_eq!( + h.zone.values(&wildcard, RecordType::Cname).unwrap(), + [name.as_str()] + ); +} + +#[tokio::test] +async fn records_put_refuses_empty_private_and_malformed_sets() { + let h = harness(100).await; + let (name, token) = h.mint(Some("strict")).await; + + for records in [ + json!({}), + json!({ "a": [] }), + json!({ "a": ["10.0.0.1"] }), + json!({ "a": ["127.0.0.1"] }), + json!({ "aaaa": ["fd00::1"] }), + ] { + let (status, body) = h + .authed( + Method::PUT, + &name, + "/records", + &token, + Some(records.clone()), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{records}"); + assert_eq!(body["error"], "invalid_records"); + } + let (status, body) = h + .authed( + Method::PUT, + &name, + "/records", + &token, + Some(json!({ "a": ["nope"] })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"], "invalid_request"); + assert!(h.zone.is_empty()); +} + +#[tokio::test] +async fn authenticated_calls_renew_the_seven_day_lease() { + let h = harness(100).await; + let (name, token) = h.mint(Some("leased")).await; + sqlx::query("UPDATE domains SET lease_expires_at = now() + interval '1 hour'") + .execute(&h.db) + .await + .unwrap(); + + let records = json!({ "a": ["203.0.113.1"] }); + assert_eq!( + h.put_records(&name, &token, records).await, + StatusCode::NO_CONTENT + ); + let renewed: bool = sqlx::query_scalar( + "SELECT lease_expires_at > now() + interval '6 days 23 hours' FROM domains", + ) + .fetch_one(&h.db) + .await + .unwrap(); + assert!(renewed); +} + +#[tokio::test] +async fn release_retires_the_name_forever() { + let h = harness(100).await; + let (name, token) = h.mint(Some("goner")).await; + let records = json!({ "a": ["203.0.113.1"], "aaaa": ["2001:db8::1"] }); + assert_eq!( + h.put_records(&name, &token, records).await, + StatusCode::NO_CONTENT + ); + + let (status, _) = h.authed(Method::DELETE, &name, "", &token, None).await; + assert_eq!(status, StatusCode::NO_CONTENT); + assert!(h.zone.is_empty()); + + let (status, body) = h.authed(Method::POST, &name, "/lease", &token, None).await; + assert_eq!(status, StatusCode::GONE); + assert_eq!(body["error"], "retired"); + let (reissued, _) = h.mint(Some("goner")).await; + assert_ne!(reissued, name); +} + +#[tokio::test] +async fn reaper_frees_unused_reservations_and_retires_expired_leases() { + let h = harness(100).await; + let (unused, unused_token) = h.mint(Some("unused")).await; + let (fresh, fresh_token) = h.mint(Some("fresh")).await; + let (expired, expired_token) = h.mint(Some("expired")).await; + let (live, live_token) = h.mint(Some("live")).await; + let records = json!({ "a": ["203.0.113.1"] }); + assert_eq!( + h.put_records(&expired, &expired_token, records.clone()) + .await, + StatusCode::NO_CONTENT + ); + assert_eq!( + h.put_records(&live, &live_token, records).await, + StatusCode::NO_CONTENT + ); + + sqlx::query("UPDATE domains SET reserved_at = now() - interval '25 hours' WHERE name <> $1") + .bind(&fresh) + .execute(&h.db) + .await + .unwrap(); + sqlx::query( + "UPDATE domains SET lease_expires_at = now() - interval '1 minute' WHERE name = $1", + ) + .bind(&expired) + .execute(&h.db) + .await + .unwrap(); + reap(&h.state).await.unwrap(); + + // Unused for 24 hours: gone, and the label is free again. + let (status, _) = h + .authed(Method::POST, &unused, "/lease", &unused_token, None) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(h.mint(Some("unused")).await.0, unused); + // Expired lease: records removed, name retired. + let (status, _) = h + .authed(Method::POST, &expired, "/lease", &expired_token, None) + .await; + assert_eq!(status, StatusCode::GONE); + assert_eq!(h.zone.values(&expired, RecordType::A), None); + assert_eq!( + h.zone.values(&format!("*.{expired}"), RecordType::Cname), + None + ); + // Young reservations and live leases stay. + let (status, _) = h + .authed(Method::POST, &fresh, "/lease", &fresh_token, None) + .await; + assert_eq!(status, StatusCode::OK); + let (status, _) = h + .authed(Method::POST, &live, "/lease", &live_token, None) + .await; + assert_eq!(status, StatusCode::OK); + assert!(h.zone.values(&live, RecordType::A).is_some()); +} From fc4e3fff55e94f2a936e9d7675e8a73cc7026d44 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 25 Sep 2026 04:34:17 +0000 Subject: [PATCH 2/7] Sign one wildcard certificate per Cluster Domain from a CSR POST /domains/{name}/certificate takes a CSR naming exactly name and *.name, orders both from the configured ACME CA (Google Trust Services, EAB-bound account), answers DNS-01 with a TXT record in the zone, waits for Route 53 to report INSYNC, finalises and returns the PEM chain. The challenge is removed either way. The service never sees the private key and stores no certificate. A CA 429 pauses all issuance until its Retry-After and is passed back as 429 ca_rate_limited. The ACME account credentials are kept in Postgres because EAB keys are single use. --- .env.example | 7 + Cargo.lock | 361 +++++++++++++++++++++++++++++- Cargo.toml | 9 + README.md | 3 +- migrations/0002_acme_accounts.sql | 7 + src/acme.rs | 236 +++++++++++++++++++ src/api.rs | 122 +++++++--- src/cert.rs | 129 +++++++++++ src/lib.rs | 9 +- src/main.rs | 20 +- src/route53.rs | 53 ++++- src/zone.rs | 19 +- tests/api.rs | 240 +++++++++++++++++++- 13 files changed, 1162 insertions(+), 53 deletions(-) create mode 100644 migrations/0002_acme_accounts.sql create mode 100644 src/acme.rs create mode 100644 src/cert.rs diff --git a/.env.example b/.env.example index 826131e..76a7c39 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,10 @@ MINTS_PER_HOUR=30 # Set when behind a proxy that overwrites this header with the client IP. CLIENT_IP_HEADER=x-real-ip RUST_LOG=info +# ACME account for wildcard certificates (Google Trust Services, bound with EAB). +# Production directory: https://dv.acme-v02.api.pki.goog/directory +# Staging directory: https://dv.acme-v02.test-api.pki.goog/directory +ACME_DIRECTORY_URL=https://dv.acme-v02.test-api.pki.goog/directory +ACME_EAB_KID= +# base64url, as `gcloud publicca external-account-keys create` prints it. +ACME_EAB_HMAC_KEY= diff --git a/Cargo.lock b/Cargo.lock index 2cb8753..07d638a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,45 @@ dependencies = [ "rustversion", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "astral-tokio-tar" version = "0.6.4" @@ -161,6 +200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -629,6 +669,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -851,6 +900,16 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -989,6 +1048,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "defmt" version = "1.1.1" @@ -1031,6 +1096,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1490,10 +1569,18 @@ dependencies = [ "aws-config", "aws-sdk-route53", "axum", + "base64 0.23.1", "bcrypt", + "bytes", "chrono", "http-body-util", + "httpdate", + "hyper-rustls 0.27.10", + "hyper-util", + "instant-acme", "rand 0.9.5", + "rcgen", + "rustls 0.23.45", "serde", "serde_json", "sqlx", @@ -1503,6 +1590,7 @@ dependencies = [ "tower", "tracing", "tracing-subscriber", + "x509-parser", ] [[package]] @@ -1667,6 +1755,7 @@ dependencies = [ "hyper-util", "rustls 0.23.45", "rustls-native-certs", + "rustls-platform-verifier", "tokio", "tokio-rustls 0.26.5", "tower-service", @@ -1890,6 +1979,32 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "instant-acme" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f05ad37c421b962354c358d347d4a6130151df9407978372d3ad7f0c8f71a64" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64 0.22.1", + "bytes", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "httpdate", + "hyper 1.11.1", + "hyper-rustls 0.27.10", + "hyper-util", + "rcgen", + "rustls 0.23.45", + "rustls-pki-types", + "serde", + "serde_json", + "thiserror", + "tokio", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -1965,6 +2080,55 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "jobserver" version = "0.1.35" @@ -2093,6 +2257,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.3" @@ -2104,6 +2274,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2208,6 +2388,15 @@ dependencies = [ "libm", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2280,6 +2469,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2537,6 +2736,21 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "aws-lc-rs", + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2620,7 +2834,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -2659,6 +2873,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.5" @@ -2721,6 +2944,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.45", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.15", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -2728,7 +2978,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2740,7 +2990,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2755,6 +3005,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -2801,7 +3060,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -3011,6 +3270,22 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -3339,6 +3614,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -3709,6 +3995,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -3807,6 +4099,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3892,6 +4194,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -3936,6 +4247,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -4161,6 +4481,25 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -4177,6 +4516,16 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "yoke" version = "0.8.3" @@ -4197,7 +4546,7 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.6", - "synstructure", + "synstructure 0.14.0", ] [[package]] @@ -4238,7 +4587,7 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.6", - "synstructure", + "synstructure 0.14.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b3666c0..d215021 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,16 @@ edition = "2024" aws-config = { version = "1.12.0", features = ["behavior-version-latest"] } aws-sdk-route53 = "1.125.0" axum = "0.8.9" +base64 = "0.23.1" bcrypt = "0.19.3" +bytes = "1.12.1" chrono = { version = "0.4.45", features = ["serde"] } +httpdate = "1.0.3" +hyper-rustls = { version = "0.27.10", default-features = false, features = ["http1", "http2", "native-tokio", "tls12", "rustls-platform-verifier", "aws-lc-rs"] } +hyper-util = { version = "0.1.21", features = ["client-legacy", "tokio", "http1", "http2"] } +instant-acme = "0.8.5" rand = "0.9" +rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "migrate", "macros"] } @@ -17,9 +24,11 @@ thiserror = "2.0.21" tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +x509-parser = "0.18.1" [dev-dependencies] http-body-util = "0.1.5" +rcgen = "0.14.10" testcontainers-modules = { version = "0.15.0", features = ["postgres"] } tower = { version = "0.5.3", features = ["util"] } diff --git a/README.md b/README.md index 3bbe609..9c8d472 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,5 @@ Rust axum service backed by Route 53 and Postgres. Configuration comes from the environment; see `.env.example`. Migrations run at startup. Tests need Docker: `cargo test` starts a throwaway Postgres per test -(testcontainers) and runs the HTTP API against an in-memory fake Route 53. +(testcontainers) and runs the HTTP API against an in-memory fake Route 53 and +a fake ACME CA that validates DNS-01 through it. diff --git a/migrations/0002_acme_accounts.sql b/migrations/0002_acme_accounts.sql new file mode 100644 index 0000000..2cbf8d1 --- /dev/null +++ b/migrations/0002_acme_accounts.sql @@ -0,0 +1,7 @@ +-- The ACME account for each directory. EAB keys are single use, so the +-- account key must outlive restarts. This is the CA account key, never a +-- certificate key. +CREATE TABLE acme_accounts ( + directory_url TEXT PRIMARY KEY, + credentials TEXT NOT NULL +); diff --git a/src/acme.rs b/src/acme.rs new file mode 100644 index 0000000..15f7f31 --- /dev/null +++ b/src/acme.rs @@ -0,0 +1,236 @@ +//! [`Ca`] backed by an ACME server (Google Trust Services) with an EAB-bound account. + +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; + +use axum::http::{HeaderMap, Request, StatusCode, header}; +use bytes::Bytes; +use hyper_util::client::legacy::Client as HyperClient; +use hyper_util::rt::TokioExecutor; +use instant_acme::{ + Account, AccountCredentials, AuthorizationStatus, BodyWrapper, BytesResponse, ChallengeType, + ExternalAccountKey, HttpClient, Identifier, NewAccount, NewOrder, Order, OrderStatus, + RetryPolicy, +}; +use sqlx::PgPool; +use tokio::sync::OnceCell; + +use crate::cert::{Ca, CaError}; + +/// Order validation and certificate download get this long. +const POLL: RetryPolicy = RetryPolicy::new() + .initial_delay(Duration::from_secs(2)) + .backoff(1.5) + .timeout(Duration::from_secs(300)); +/// Used when a 429 carries no usable `Retry-After`. +const DEFAULT_BACKOFF: Duration = Duration::from_secs(3600); + +/// Where the ACME account lives and how to create it. +pub struct AcmeConfig { + pub directory_url: String, + pub eab_kid: String, + /// Raw HMAC key bytes (the CA hands them out base64url-encoded). + pub eab_hmac: Vec, +} + +/// The production CA. +pub struct Acme { + config: AcmeConfig, + db: PgPool, + account: OnceCell, + rate_limited_until: Arc>>, +} + +impl Acme { + /// The account is created (or loaded from Postgres) on first use. + pub fn new(config: AcmeConfig, db: PgPool) -> Self { + Self { + config, + db, + account: OnceCell::new(), + rate_limited_until: Arc::default(), + } + } + + /// EAB keys are single use, so the account's credentials are kept in Postgres. + async fn account(&self) -> Result<&Account, CaError> { + self.account + .get_or_try_init(|| async { + let url = &self.config.directory_url; + let builder = Account::builder_with_http(Box::new(self.http()?)); + let stored: Option = sqlx::query_scalar( + "SELECT credentials FROM acme_accounts WHERE directory_url = $1", + ) + .bind(url) + .fetch_optional(&self.db) + .await + .map_err(failed)?; + if let Some(stored) = stored { + let credentials: AccountCredentials = + serde_json::from_str(&stored).map_err(failed)?; + return builder + .from_credentials(credentials) + .await + .map_err(|error| self.ca_error(error)); + } + let eab = + ExternalAccountKey::new(self.config.eab_kid.clone(), &self.config.eab_hmac); + let new_account = NewAccount { + contact: &[], + terms_of_service_agreed: true, + only_return_existing: false, + }; + let (account, credentials) = builder + .create(&new_account, url.clone(), Some(&eab)) + .await + .map_err(|error| self.ca_error(error))?; + sqlx::query( + "INSERT INTO acme_accounts (directory_url, credentials) VALUES ($1, $2)", + ) + .bind(url) + .bind(serde_json::to_string(&credentials).map_err(failed)?) + .execute(&self.db) + .await + .map_err(failed)?; + tracing::info!(id = account.id(), "created ACME account"); + Ok(account) + }) + .await + } + + fn http(&self) -> Result { + let https = hyper_rustls::HttpsConnectorBuilder::new() + .try_with_platform_verifier() + .map_err(failed)? + .https_only() + .enable_http1() + .enable_http2() + .build(); + Ok(RetryAfterRecorder { + inner: HyperClient::builder(TokioExecutor::new()).build(https), + until: Arc::clone(&self.rate_limited_until), + }) + } + + fn ca_error(&self, error: instant_acme::Error) -> CaError { + match &error { + instant_acme::Error::Api(problem) if problem.status == Some(429) => { + let until = self + .rate_limited_until + .lock() + .expect("retry-after lock is never poisoned") + .take() + .unwrap_or_else(|| SystemTime::now() + DEFAULT_BACKOFF); + CaError::RateLimited { until } + } + _ => CaError::Failed(error.to_string()), + } + } +} + +impl Ca for Acme { + type Order = Order; + + async fn new_order(&self, names: &[String]) -> Result<(Order, Vec), CaError> { + let identifiers: Vec<_> = names.iter().cloned().map(Identifier::Dns).collect(); + let mut order = self + .account() + .await? + .new_order(&NewOrder::new(&identifiers)) + .await + .map_err(|error| self.ca_error(error))?; + let mut values = Vec::new(); + let mut authorizations = order.authorizations(); + while let Some(authorization) = authorizations.next().await { + let mut authorization = authorization.map_err(|error| self.ca_error(error))?; + match authorization.status { + AuthorizationStatus::Valid => {} + AuthorizationStatus::Pending => { + let challenge = authorization + .challenge(ChallengeType::Dns01) + .ok_or_else(|| CaError::Failed("CA offered no dns-01 challenge".into()))?; + values.push(challenge.key_authorization().dns_value()); + } + status => return Err(CaError::Failed(format!("authorization is {status:?}"))), + } + } + Ok((order, values)) + } + + async fn finalize(&self, mut order: Order, csr_der: &[u8]) -> Result { + let mut authorizations = order.authorizations(); + while let Some(authorization) = authorizations.next().await { + let mut authorization = authorization.map_err(|error| self.ca_error(error))?; + if authorization.status != AuthorizationStatus::Pending { + continue; + } + let mut challenge = authorization + .challenge(ChallengeType::Dns01) + .ok_or_else(|| CaError::Failed("CA offered no dns-01 challenge".into()))?; + challenge + .set_ready() + .await + .map_err(|error| self.ca_error(error))?; + } + let status = order + .poll_ready(&POLL) + .await + .map_err(|error| self.ca_error(error))?; + if status != OrderStatus::Ready { + return Err(CaError::Failed(format!( + "order is {status:?} after validation" + ))); + } + order + .finalize_csr(csr_der) + .await + .map_err(|error| self.ca_error(error))?; + order + .poll_certificate(&POLL) + .await + .map_err(|error| self.ca_error(error)) + } +} + +type HttpsClient = HyperClient< + hyper_rustls::HttpsConnector, + BodyWrapper, +>; + +/// instant-acme drops `Retry-After` from 429 problems, so record it on the way through. +struct RetryAfterRecorder { + inner: HttpsClient, + until: Arc>>, +} + +impl HttpClient for RetryAfterRecorder { + fn request( + &self, + request: Request>, + ) -> Pin> + Send>> { + let response = HttpClient::request(&self.inner, request); + let until = Arc::clone(&self.until); + Box::pin(async move { + let response = response.await?; + if response.parts.status == StatusCode::TOO_MANY_REQUESTS { + *until.lock().expect("retry-after lock is never poisoned") = + retry_after(&response.parts.headers); + } + Ok(response) + }) + } +} + +/// `Retry-After` as delay-seconds or an HTTP date. +fn retry_after(headers: &HeaderMap) -> Option { + let value = headers.get(header::RETRY_AFTER)?.to_str().ok()?.trim(); + match value.parse::() { + Ok(secs) => Some(SystemTime::now() + Duration::from_secs(secs)), + Err(_) => httpdate::parse_http_date(value).ok(), + } +} + +fn failed(error: impl std::fmt::Display) -> CaError { + CaError::Failed(error.to_string()) +} diff --git a/src/api.rs b/src/api.rs index e1dc932..5e00755 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,9 +1,9 @@ -//! HTTP API: mint, rotate, records, lease, release, and the reaper. +//! HTTP API: mint, rotate, records, lease, release, certificate, and the reaper. use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime}; use axum::extract::rejection::JsonRejection; use axum::extract::{ConnectInfo, Path, State}; @@ -15,6 +15,7 @@ use chrono::{DateTime, TimeDelta, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{PgConnection, PgPool}; +use crate::cert::{self, Ca, CaError}; use crate::label; use crate::zone::{Change, RecordSet, RecordType, Zone, ZoneError}; @@ -39,15 +40,18 @@ pub struct Config { } /// Shared state for every request. -pub struct AppState { +pub struct AppState { db: PgPool, zone: Z, + ca: C, config: Config, mints: MintLimiter, + /// The CA's quota is per account, so one 429 pauses every caller. + ca_blocked_until: Mutex>, } -impl AppState { - pub fn new(db: PgPool, zone: Z, config: Config) -> Self { +impl AppState { + pub fn new(db: PgPool, zone: Z, ca: C, config: Config) -> Self { let mints = MintLimiter { per_hour: config.mints_per_hour, windows: Mutex::default(), @@ -55,21 +59,24 @@ impl AppState { Self { db, zone, + ca, config, mints, + ca_blocked_until: Mutex::default(), } } } /// The service's routes. -pub fn router(state: Arc>) -> Router { +pub fn router(state: Arc>) -> Router { Router::new() .route("/healthz", get(|| async { "ok" })) - .route("/domains", post(mint::)) - .route("/domains/{name}", delete(release::)) - .route("/domains/{name}/rotate", post(rotate::)) - .route("/domains/{name}/lease", post(renew::)) - .route("/domains/{name}/records", put(put_records::)) + .route("/domains", post(mint::)) + .route("/domains/{name}", delete(release::)) + .route("/domains/{name}/rotate", post(rotate::)) + .route("/domains/{name}/lease", post(renew::)) + .route("/domains/{name}/records", put(put_records::)) + .route("/domains/{name}/certificate", post(certificate::)) .with_state(state) } @@ -84,8 +91,8 @@ struct MintResponse { token: String, } -async fn mint( - State(state): State>>, +async fn mint( + State(state): State>>, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: Result>, JsonRejection>, @@ -122,8 +129,8 @@ struct RotateResponse { token: String, } -async fn rotate( - State(state): State>>, +async fn rotate( + State(state): State>>, Path(name): Path, headers: HeaderMap, ) -> Result, ApiError> { @@ -145,8 +152,8 @@ struct LeaseResponse { lease_expires_at: DateTime, } -async fn renew( - State(state): State>>, +async fn renew( + State(state): State>>, Path(name): Path, headers: HeaderMap, ) -> Result, ApiError> { @@ -168,8 +175,8 @@ struct Records { aaaa: Vec, } -async fn put_records( - State(state): State>>, +async fn put_records( + State(state): State>>, Path(name): Path, headers: HeaderMap, body: Result, JsonRejection>, @@ -226,8 +233,8 @@ async fn put_records( Ok(StatusCode::NO_CONTENT) } -async fn release( - State(state): State>>, +async fn release( + State(state): State>>, Path(name): Path, headers: HeaderMap, ) -> Result { @@ -238,13 +245,59 @@ async fn release( Ok(StatusCode::NO_CONTENT) } +#[derive(Deserialize)] +struct CertificateRequest { + csr: String, +} + +#[derive(Serialize)] +struct CertificateResponse { + certificate_chain_pem: String, +} + +// ponytail: no per-name lock; overlapping calls for one name clobber each other's +// challenge and one fails. Cloud runs one sync per Organization at a time. +async fn certificate( + State(state): State>>, + Path(name): Path, + headers: HeaderMap, + body: Result, JsonRejection>, +) -> Result, ApiError> { + let Json(request) = body.map_err(|rejection| ApiError::BadRequest(rejection.body_text()))?; + let mut tx = state.db.begin().await?; + authenticate(&mut tx, &name, &headers).await?; + // Issuance takes minutes; do not hold the row lock through it. + tx.commit().await?; + let csr_der = cert::csr_der(&request.csr, &name)?; + + let blocked_until = *state + .ca_blocked_until + .lock() + .expect("ca backoff lock is never poisoned"); + if let Some(until) = blocked_until + && until > SystemTime::now() + { + return Err(CaError::RateLimited { until }.into()); + } + let issued = cert::issue(&state.zone, &state.ca, &name, &csr_der).await; + if let Err(ApiError::Ca(CaError::RateLimited { until })) = &issued { + *state + .ca_blocked_until + .lock() + .expect("ca backoff lock is never poisoned") = Some(*until); + } + Ok(Json(CertificateResponse { + certificate_chain_pem: issued?, + })) +} + /// Frees reservations that published no records within 24 hours, and retires /// names whose lease expired (removing their records). /// /// # Errors /// /// Returns [`ApiError`] on the first database or Route 53 failure; the next run retries. -pub async fn reap(state: &AppState) -> Result<(), ApiError> { +pub async fn reap(state: &AppState) -> Result<(), ApiError> { sqlx::query( "DELETE FROM domains WHERE retired_at IS NULL AND NOT has_records AND reserved_at < now() - $1", @@ -425,6 +478,8 @@ pub enum ApiError { BadRequest(String), #[error("{0}")] InvalidRecords(String), + #[error("{0}")] + InvalidCsr(String), #[error("missing or wrong bearer token")] Unauthorized, #[error("no such domain")] @@ -437,6 +492,8 @@ pub enum ApiError { NamespaceExhausted, #[error("dns provider: {0}")] Zone(#[from] ZoneError), + #[error("certificate authority: {0}")] + Ca(#[from] CaError), #[error("database: {0}")] Db(#[from] sqlx::Error), #[error("token hashing: {0}")] @@ -448,15 +505,21 @@ impl IntoResponse for ApiError { let (status, code) = match &self { Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"), Self::InvalidRecords(_) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_records"), + Self::InvalidCsr(_) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_csr"), Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"), Self::NotFound => (StatusCode::NOT_FOUND, "not_found"), Self::Retired => (StatusCode::GONE, "retired"), Self::RateLimited { .. } => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"), Self::NamespaceExhausted => (StatusCode::SERVICE_UNAVAILABLE, "namespace_exhausted"), Self::Zone(_) => (StatusCode::BAD_GATEWAY, "dns_provider_error"), + Self::Ca(CaError::RateLimited { .. }) => { + (StatusCode::TOO_MANY_REQUESTS, "ca_rate_limited") + } + Self::Ca(CaError::Failed(_)) => (StatusCode::BAD_GATEWAY, "ca_error"), Self::Db(_) | Self::Hash(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal"), }; - let message = if status.is_server_error() { + // AWS errors can carry account ids; CA problems are safe and Cloud needs the reason. + let message = if matches!(self, Self::Zone(_) | Self::Db(_) | Self::Hash(_)) { tracing::error!(error = %self, "request failed"); "internal error".to_owned() } else { @@ -464,10 +527,19 @@ impl IntoResponse for ApiError { }; let body = Json(serde_json::json!({ "error": code, "message": message })); let mut response = (status, body).into_response(); - if let Self::RateLimited { retry_after_secs } = self { + let retry_after_secs = match self { + Self::RateLimited { retry_after_secs } => Some(retry_after_secs), + Self::Ca(CaError::RateLimited { until }) => Some( + until + .duration_since(SystemTime::now()) + .map_or(1, |left| left.as_secs().max(1)), + ), + _ => None, + }; + if let Some(secs) = retry_after_secs { response .headers_mut() - .insert(header::RETRY_AFTER, HeaderValue::from(retry_after_secs)); + .insert(header::RETRY_AFTER, HeaderValue::from(secs)); } response } diff --git a/src/cert.rs b/src/cert.rs new file mode 100644 index 0000000..a5b5245 --- /dev/null +++ b/src/cert.rs @@ -0,0 +1,129 @@ +//! Wildcard certificates: CSR scope check and DNS-01 issuance through the zone. + +use std::collections::BTreeSet; +use std::time::SystemTime; + +use x509_parser::certification_request::X509CertificationRequest; +use x509_parser::extensions::{GeneralName, ParsedExtension}; +use x509_parser::prelude::FromDer; + +use crate::api::ApiError; +use crate::zone::{Change, RecordSet, RecordType, Zone}; + +const CHALLENGE_TTL: i64 = 60; + +/// The certificate authority seam: Google Trust Services over ACME in +/// production, a fake in tests. +pub trait Ca: Send + Sync + 'static { + /// An open order, carried from [`Ca::new_order`] to [`Ca::finalize`]. + type Order: Send; + + /// Opens an order for `names` and returns the DNS-01 TXT values the CA + /// expects at `_acme-challenge.`. Empty when every authorization is + /// already valid. + /// + /// # Errors + /// + /// Returns [`CaError`] when the CA refuses or fails the order. + fn new_order( + &self, + names: &[String], + ) -> impl Future), CaError>> + Send; + + /// Tells the CA the challenges are live, waits for validation, submits + /// `csr_der` and returns the PEM certificate chain. + /// + /// # Errors + /// + /// Returns [`CaError`] when validation, finalisation or download fails. + fn finalize( + &self, + order: Self::Order, + csr_der: &[u8], + ) -> impl Future> + Send; +} + +/// A CA failure. +#[derive(Debug, thiserror::Error)] +pub enum CaError { + /// The CA answered 429; no call may reach it before `until`. + #[error("certificate authority rate limit")] + RateLimited { until: SystemTime }, + #[error("{0}")] + Failed(String), +} + +/// Checks that `pem` is a CSR naming exactly `name` and `*.name`, and returns its DER. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidCsr`] for anything else. +pub(crate) fn csr_der(pem: &str, name: &str) -> Result, ApiError> { + let invalid = |why: &str| ApiError::InvalidCsr(why.to_owned()); + let (_, pem) = + x509_parser::pem::parse_x509_pem(pem.as_bytes()).map_err(|_| invalid("csr is not PEM"))?; + if pem.label != "CERTIFICATE REQUEST" { + return Err(invalid("PEM block is not a CERTIFICATE REQUEST")); + } + let (_, csr) = X509CertificationRequest::from_der(&pem.contents) + .map_err(|_| invalid("csr is not a valid PKCS#10 request"))?; + + let expected = BTreeSet::from([name.to_owned(), format!("*.{name}")]); + let mut requested = BTreeSet::new(); + for extension in csr.requested_extensions().into_iter().flatten() { + let ParsedExtension::SubjectAlternativeName(san) = extension else { + continue; + }; + for general_name in &san.general_names { + let GeneralName::DNSName(dns) = general_name else { + return Err(invalid("csr may only name DNS names")); + }; + requested.insert((*dns).to_owned()); + } + } + let common_names_ok = csr + .certification_request_info + .subject + .iter_common_name() + .all(|cn| cn.as_str().is_ok_and(|cn| expected.contains(cn))); + if requested != expected || !common_names_ok { + return Err(ApiError::InvalidCsr(format!( + "csr must name exactly {name} and *.{name}" + ))); + } + Ok(pem.contents) +} + +/// Orders `name` + `*.name`, publishes the DNS-01 TXT record, waits for Route 53 +/// to sync, finalises with `csr_der` and returns the chain. The TXT record is +/// removed whether or not issuance succeeds. +pub(crate) async fn issue( + zone: &Z, + ca: &C, + name: &str, + csr_der: &[u8], +) -> Result { + let names = [name.to_owned(), format!("*.{name}")]; + let (order, values) = ca.new_order(&names).await?; + if values.is_empty() { + return Ok(ca.finalize(order, csr_der).await?); + } + // Both authorizations validate at the same name, so one set holds both values. + let challenge = RecordSet { + name: format!("_acme-challenge.{name}"), + kind: RecordType::Txt, + ttl: CHALLENGE_TTL, + values, + }; + let change = zone.apply(vec![Change::Upsert(challenge.clone())]).await?; + let issued = async { + zone.wait_in_sync(&change).await?; + Ok(ca.finalize(order, csr_der).await?) + } + .await; + if let Err(error) = zone.apply(vec![Change::Delete(challenge)]).await { + // The next issuance for this name overwrites it with an upsert. + tracing::warn!(%name, %error, "could not remove DNS-01 challenge"); + } + issued +} diff --git a/src/lib.rs b/src/lib.rs index 0985775..8bc84c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,19 @@ //! Hosted DNS: mints generated Cluster Domains under one Route 53 zone and -//! publishes their apex addresses for Ployz Cloud. +//! publishes their apex addresses for Ployz Cloud. Signs one wildcard +//! certificate per name from a CSR; it never holds certificate keys. +mod acme; mod api; +mod cert; mod label; mod route53; mod zone; +pub use acme::{Acme, AcmeConfig}; pub use api::{ApiError, AppState, Config, reap, router}; +pub use cert::{Ca, CaError}; pub use route53::Route53; -pub use zone::{Change, RecordSet, RecordType, Zone, ZoneError}; +pub use zone::{Change, ChangeId, RecordSet, RecordType, Zone, ZoneError}; /// Schema migrations, run at startup. pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!(); diff --git a/src/main.rs b/src/main.rs index c2fce0d..02d70f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,9 @@ use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; -use hosted_dns::{AppState, Config, MIGRATOR, Route53, reap, router}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use hosted_dns::{Acme, AcmeConfig, AppState, Config, MIGRATOR, Route53, reap, router}; use sqlx::postgres::PgPoolOptions; use tracing_subscriber::EnvFilter; @@ -12,6 +14,10 @@ const REAP_EVERY: Duration = Duration::from_secs(300); #[tokio::main] async fn main() -> Result<(), Box> { + // sqlx pulls in ring and hyper-rustls aws-lc-rs; rustls needs to be told which to use. + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("no other crypto provider is installed first"); tracing_subscriber::fmt() .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) .init(); @@ -23,12 +29,22 @@ async fn main() -> Result<(), Box> { MIGRATOR.run(&db).await?; let aws = aws_config::load_from_env().await; let zone = Route53::new(&aws, required("HOSTED_ZONE_ID")?); + let ca = Acme::new( + AcmeConfig { + directory_url: required("ACME_DIRECTORY_URL")?, + eab_kid: required("ACME_EAB_KID")?, + eab_hmac: URL_SAFE_NO_PAD + .decode(required("ACME_EAB_HMAC_KEY")?.trim_end_matches('=')) + .map_err(|_| "ACME_EAB_HMAC_KEY is not base64url")?, + }, + db.clone(), + ); let config = Config { apex: required("APEX_DOMAIN")?, mints_per_hour: optional("MINTS_PER_HOUR")?.unwrap_or(30), client_ip_header: optional("CLIENT_IP_HEADER")?, }; - let state = Arc::new(AppState::new(db, zone, config)); + let state = Arc::new(AppState::new(db, zone, ca, config)); let reaper = Arc::clone(&state); tokio::spawn(async move { diff --git a/src/route53.rs b/src/route53.rs index 15ba206..c3b32fe 100644 --- a/src/route53.rs +++ b/src/route53.rs @@ -1,9 +1,14 @@ //! [`Zone`] backed by one Route 53 hosted zone. +use std::time::Duration; + use aws_sdk_route53::error::DisplayErrorContext; use aws_sdk_route53::types as r53; -use crate::zone::{Change, RecordSet, RecordType, Zone, ZoneError}; +use crate::zone::{Change, ChangeId, RecordSet, RecordType, Zone, ZoneError}; + +const SYNC_POLL: Duration = Duration::from_secs(3); +const SYNC_TIMEOUT: Duration = Duration::from_secs(300); /// The production hosted zone. pub struct Route53 { @@ -42,6 +47,7 @@ impl Zone for Route53 { r53::RrType::A => RecordType::A, r53::RrType::Aaaa => RecordType::Aaaa, r53::RrType::Cname => RecordType::Cname, + r53::RrType::Txt => RecordType::Txt, _ => return None, }; Some(RecordSet { @@ -51,14 +57,17 @@ impl Zone for Route53 { values: set .resource_records() .iter() - .map(|record| record.value().to_owned()) + .map(|record| match kind { + RecordType::Txt => record.value().trim_matches('"').to_owned(), + _ => record.value().to_owned(), + }) .collect(), }) }) .collect()) } - async fn apply(&self, changes: Vec) -> Result<(), ZoneError> { + async fn apply(&self, changes: Vec) -> Result { let changes = changes .into_iter() .map(to_r53) @@ -68,14 +77,43 @@ impl Zone for Route53 { .set_changes(Some(changes)) .build() .map_err(zone_error)?; - self.client + let output = self + .client .change_resource_record_sets() .hosted_zone_id(&self.zone_id) .change_batch(batch) .send() .await .map_err(zone_error)?; - Ok(()) + let id = output + .change_info() + .map(|info| info.id().to_owned()) + .ok_or_else(|| ZoneError("Route 53 returned no change id".into()))?; + Ok(ChangeId(id)) + } + + async fn wait_in_sync(&self, change: &ChangeId) -> Result<(), ZoneError> { + let wait = async { + loop { + let output = self + .client + .get_change() + .id(&change.0) + .send() + .await + .map_err(zone_error)?; + let in_sync = output + .change_info() + .is_some_and(|info| *info.status() == r53::ChangeStatus::Insync); + if in_sync { + return Ok(()); + } + tokio::time::sleep(SYNC_POLL).await; + } + }; + tokio::time::timeout(SYNC_TIMEOUT, wait) + .await + .map_err(|_| ZoneError(format!("change {} not in sync after 5 min", change.0)))? } } @@ -87,12 +125,17 @@ fn to_r53(change: Change) -> Result format!("\"{value}\""), + _ => value.clone(), + }) .map(|value| r53::ResourceRecord::builder().value(value).build()) .collect::, _>>()?; let kind = match set.kind { RecordType::A => r53::RrType::A, RecordType::Aaaa => r53::RrType::Aaaa, RecordType::Cname => r53::RrType::Cname, + RecordType::Txt => r53::RrType::Txt, }; let set = r53::ResourceRecordSet::builder() .name(set.name) diff --git a/src/zone.rs b/src/zone.rs index 55b4267..9f8d42e 100644 --- a/src/zone.rs +++ b/src/zone.rs @@ -6,6 +6,8 @@ pub enum RecordType { A, Aaaa, Cname, + /// Unquoted values; the provider adds and strips the quotes. + Txt, } /// One Route 53 resource record set. @@ -25,6 +27,10 @@ pub enum Change { Delete(RecordSet), } +/// Identifies an applied batch so callers can wait for it to reach every nameserver. +#[derive(Clone, Debug)] +pub struct ChangeId(pub String); + /// The DNS provider refused or failed a call. #[derive(Debug, thiserror::Error)] #[error("{0}")] @@ -47,5 +53,16 @@ pub trait Zone: Send + Sync + 'static { /// # Errors /// /// Returns [`ZoneError`] when the provider rejects or fails the batch. - fn apply(&self, changes: Vec) -> impl Future> + Send; + fn apply( + &self, + changes: Vec, + ) -> impl Future> + Send; + + /// Waits until the batch is served by every authoritative nameserver. + /// + /// # Errors + /// + /// Returns [`ZoneError`] when the provider call fails or the wait times out. + fn wait_in_sync(&self, change: &ChangeId) + -> impl Future> + Send; } diff --git a/tests/api.rs b/tests/api.rs index a611aae..2033ed0 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -1,15 +1,17 @@ -//! HTTP API tests against a fake Route 53 and a throwaway Postgres container. +//! HTTP API tests against a fake Route 53, a fake ACME CA and a throwaway Postgres container. use std::collections::HashMap; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; use axum::Router; use axum::body::Body; use axum::extract::connect_info::MockConnectInfo; use axum::http::{Method, Request, StatusCode}; use hosted_dns::{ - AppState, Change, Config, MIGRATOR, RecordSet, RecordType, Zone, ZoneError, reap, router, + AppState, Ca, CaError, Change, ChangeId, Config, MIGRATOR, RecordSet, RecordType, Zone, + ZoneError, reap, router, }; use http_body_util::BodyExt; use serde_json::{Value, json}; @@ -21,25 +23,40 @@ use tower::ServiceExt; const APEX: &str = "ployz.test"; -/// Route 53 semantics in memory: batches are atomic and a delete must match exactly. +type Sets = HashMap<(String, RecordType), RecordSet>; + +/// Route 53 semantics in memory: batches are atomic, a delete must match +/// exactly, and changes reach the public view (what resolvers see) only once +/// someone waits for them to sync. #[derive(Clone, Default)] -struct FakeZone(Arc>>); +struct FakeZone { + sets: Arc>, + public: Arc>, +} impl FakeZone { fn values(&self, name: &str, kind: RecordType) -> Option> { - let sets = self.0.lock().unwrap(); + let sets = self.sets.lock().unwrap(); sets.get(&(name.to_owned(), kind)) .map(|set| set.values.clone()) } + fn resolve(&self, name: &str, kind: RecordType) -> Vec { + let public = self.public.lock().unwrap(); + public + .get(&(name.to_owned(), kind)) + .map(|set| set.values.clone()) + .unwrap_or_default() + } + fn is_empty(&self) -> bool { - self.0.lock().unwrap().is_empty() + self.sets.lock().unwrap().is_empty() } } impl Zone for FakeZone { async fn record_sets(&self, name: &str) -> Result, ZoneError> { - let sets = self.0.lock().unwrap(); + let sets = self.sets.lock().unwrap(); Ok(sets .values() .filter(|set| set.name == name) @@ -47,8 +64,8 @@ impl Zone for FakeZone { .collect()) } - async fn apply(&self, changes: Vec) -> Result<(), ZoneError> { - let mut sets = self.0.lock().unwrap(); + async fn apply(&self, changes: Vec) -> Result { + let mut sets = self.sets.lock().unwrap(); let mut next = sets.clone(); for change in changes { match change { @@ -63,14 +80,79 @@ impl Zone for FakeZone { } } *sets = next; + Ok(ChangeId("change".into())) + } + + async fn wait_in_sync(&self, _change: &ChangeId) -> Result<(), ZoneError> { + *self.public.lock().unwrap() = self.sets.lock().unwrap().clone(); Ok(()) } } +/// An ACME CA that validates DNS-01 by resolving through the fake zone. +#[derive(Clone)] +struct FakeCa { + zone: FakeZone, + rate_limit_next_order: Arc>>, + refuse_next_finalize: Arc>, +} + +struct FakeOrder { + names: Vec, + values: Vec, +} + +impl Ca for FakeCa { + type Order = FakeOrder; + + async fn new_order(&self, names: &[String]) -> Result<(FakeOrder, Vec), CaError> { + if let Some(delay) = self.rate_limit_next_order.lock().unwrap().take() { + return Err(CaError::RateLimited { + until: SystemTime::now() + delay, + }); + } + let values: Vec = names + .iter() + .enumerate() + .map(|(i, name)| format!("dns01-{i}-{name}")) + .collect(); + let order = FakeOrder { + names: names.to_vec(), + values: values.clone(), + }; + Ok((order, values)) + } + + async fn finalize(&self, order: FakeOrder, csr_der: &[u8]) -> Result { + if std::mem::take(&mut *self.refuse_next_finalize.lock().unwrap()) { + return Err(CaError::Failed( + "urn:ietf:params:acme:error:serverInternal".into(), + )); + } + for name in &order.names { + let base = name.trim_start_matches("*."); + let published = self + .zone + .resolve(&format!("_acme-challenge.{base}"), RecordType::Txt); + if !order.values.iter().all(|value| published.contains(value)) { + return Err(CaError::Failed(format!("dns-01 failed for {name}"))); + } + } + let csr = String::from_utf8_lossy(csr_der); + assert!(order.names.iter().all(|name| csr.contains(name.as_str()))); + Ok(format!( + "-----BEGIN CERTIFICATE-----\nleaf for {}\n-----END CERTIFICATE-----\n\ + -----BEGIN CERTIFICATE-----\nintermediate\n-----END CERTIFICATE-----\n", + order.names.join(",") + )) + } +} + struct Harness { app: Router, - state: Arc>, + state: Arc>, zone: FakeZone, + ca: FakeCa, db: PgPool, _postgres: ContainerAsync, } @@ -85,18 +167,24 @@ async fn harness(mints_per_hour: u32) -> Harness { let db = PgPool::connect(&url).await.unwrap(); MIGRATOR.run(&db).await.unwrap(); let zone = FakeZone::default(); + let ca = FakeCa { + zone: zone.clone(), + rate_limit_next_order: Arc::default(), + refuse_next_finalize: Arc::default(), + }; let config = Config { apex: APEX.into(), mints_per_hour, client_ip_header: Some("x-real-ip".parse().unwrap()), }; - let state = Arc::new(AppState::new(db.clone(), zone.clone(), config)); + let state = Arc::new(AppState::new(db.clone(), zone.clone(), ca.clone(), config)); let app = router(Arc::clone(&state)) .layer(MockConnectInfo(SocketAddr::from(([198, 51, 100, 1], 4000)))); Harness { app, state, zone, + ca, db, _postgres: postgres, } @@ -448,3 +536,133 @@ async fn reaper_frees_unused_reservations_and_retires_expired_leases() { assert_eq!(status, StatusCode::OK); assert!(h.zone.values(&live, RecordType::A).is_some()); } + +fn csr(names: &[&str]) -> String { + let key = rcgen::KeyPair::generate().unwrap(); + let names = names + .iter() + .map(|name| (*name).to_owned()) + .collect::>(); + let mut params = rcgen::CertificateParams::new(names).unwrap(); + params.distinguished_name = rcgen::DistinguishedName::new(); + params.serialize_request(&key).unwrap().pem().unwrap() +} + +#[tokio::test] +async fn certificate_is_issued_through_dns01_and_the_challenge_removed() { + let h = harness(100).await; + let (name, token) = h.mint(Some("secure")).await; + let wildcard = format!("*.{name}"); + + let body = json!({ "csr": csr(&[&name, &wildcard]) }); + let (status, body) = h + .authed(Method::POST, &name, "/certificate", &token, Some(body)) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let chain = body["certificate_chain_pem"].as_str().unwrap(); + assert!(chain.contains(&format!("leaf for {name},{wildcard}"))); + assert_eq!(chain.matches("BEGIN CERTIFICATE").count(), 2); + + let challenge = format!("_acme-challenge.{name}"); + assert_eq!(h.zone.values(&challenge, RecordType::Txt), None); +} + +#[tokio::test] +async fn certificate_refuses_csrs_outside_the_token_holders_name() { + let h = harness(100).await; + let (name, token) = h.mint(Some("scoped")).await; + let (other, _) = h.mint(Some("victim")).await; + let wildcard = format!("*.{name}"); + let other_wildcard = format!("*.{other}"); + + let with_cn = { + let key = rcgen::KeyPair::generate().unwrap(); + let params = rcgen::CertificateParams::new(vec![name.clone(), wildcard.clone()]).unwrap(); + // rcgen's default subject CN is not one of the names. + params.serialize_request(&key).unwrap().pem().unwrap() + }; + for pem in [ + csr(&[&name]), + csr(&[&wildcard]), + csr(&[&name, &wildcard, &other]), + csr(&[&other, &other_wildcard]), + csr(&[&name, "*.*.scoped.ployz.test"]), + with_cn, + "not a csr".to_owned(), + ] { + let (status, body) = h + .authed( + Method::POST, + &name, + "/certificate", + &token, + Some(json!({ "csr": pem })), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + assert_eq!(body["error"], "invalid_csr"); + } + + // Only the token holder of the name may ask. + let (status, _) = h + .authed( + Method::POST, + &other, + "/certificate", + &token, + Some(json!({ "csr": csr(&[&other, &other_wildcard]) })), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert!(h.zone.is_empty()); +} + +#[tokio::test] +async fn certificate_honours_the_cas_retry_after() { + let h = harness(100).await; + let (name, token) = h.mint(Some("patient")).await; + let body = json!({ "csr": csr(&[&name, &format!("*.{name}")]) }); + *h.ca.rate_limit_next_order.lock().unwrap() = Some(Duration::from_secs(120)); + + let request = Request::builder() + .method(Method::POST) + .uri(format!("/domains/{name}/certificate")) + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let response = h.app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + let retry_after: u64 = response.headers()["retry-after"] + .to_str() + .unwrap() + .parse() + .unwrap(); + assert!((100..=120).contains(&retry_after), "{retry_after}"); + + // The CA would accept now, but the service waits out Retry-After for every name. + let (status, body) = h + .authed(Method::POST, &name, "/certificate", &token, Some(body)) + .await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(body["error"], "ca_rate_limited"); +} + +#[tokio::test] +async fn failed_issuance_still_removes_the_challenge() { + let h = harness(100).await; + let (name, token) = h.mint(Some("unlucky")).await; + *h.ca.refuse_next_finalize.lock().unwrap() = true; + + let body = json!({ "csr": csr(&[&name, &format!("*.{name}")]) }); + let (status, body) = h + .authed(Method::POST, &name, "/certificate", &token, Some(body)) + .await; + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert_eq!(body["error"], "ca_error"); + assert_eq!( + h.zone + .values(&format!("_acme-challenge.{name}"), RecordType::Txt), + None + ); +} From df6776c5e4292395bc0786126076d90f1eee6cd4 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 25 Sep 2026 04:36:11 +0000 Subject: [PATCH 3/7] Let a mint key skip the per-IP mint limit MINT_KEYS holds comma-separated keys. POST /domains with a known key as the bearer token skips the per-IP limiter; any other Authorization header is refused with 401 rather than treated as anonymous. Keys are compared without an early exit. --- .env.example | 2 ++ src/api.rs | 46 ++++++++++++++++++++++++++++++++++++++-------- src/main.rs | 7 +++++++ tests/api.rs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 76a7c39..ebc6273 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,5 @@ ACME_DIRECTORY_URL=https://dv.acme-v02.test-api.pki.goog/directory ACME_EAB_KID= # base64url, as `gcloud publicca external-account-keys create` prints it. ACME_EAB_HMAC_KEY= +# Optional comma-separated bearer keys that let POST /domains skip the per-IP limit. +MINT_KEYS=replace-with-a-long-random-key diff --git a/src/api.rs b/src/api.rs index 5e00755..f4ac149 100644 --- a/src/api.rs +++ b/src/api.rs @@ -37,6 +37,8 @@ pub struct Config { pub mints_per_hour: u32, /// Header holding the client IP when behind a proxy; the peer address otherwise. pub client_ip_header: Option, + /// Bearer keys that let `POST /domains` skip the per-IP limit (hosted Cloud). + pub mint_keys: Vec, } /// Shared state for every request. @@ -98,8 +100,23 @@ async fn mint( body: Result>, JsonRejection>, ) -> Result<(StatusCode, Json), ApiError> { let body = body.map_err(|rejection| ApiError::BadRequest(rejection.body_text()))?; - let client = client_ip(&headers, state.config.client_ip_header.as_ref(), peer.ip()); - state.mints.check(client)?; + if headers.contains_key(header::AUTHORIZATION) { + // A caller that presents a key never falls back to anonymous minting. + let key = bearer(&headers).ok_or(ApiError::Unauthorized)?; + let known = state + .config + .mint_keys + .iter() + .fold(false, |known, candidate| { + known | constant_time_eq(candidate, key) + }); + if !known { + return Err(ApiError::Unauthorized); + } + } else { + let client = client_ip(&headers, state.config.client_ip_header.as_ref(), peer.ip()); + state.mints.check(client)?; + } let preferred = body.and_then(|Json(body)| body.preferred); let token = label::random_string(TOKEN_LEN); let token_hash = hash(&token).await?; @@ -351,12 +368,7 @@ async fn authenticate( name: &str, headers: &HeaderMap, ) -> Result, ApiError> { - let token = headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .ok_or(ApiError::Unauthorized)? - .to_owned(); + let token = bearer(headers).ok_or(ApiError::Unauthorized)?.to_owned(); let row: Option<(String, Option>)> = sqlx::query_as("SELECT token_hash, retired_at FROM domains WHERE name = $1 FOR UPDATE") .bind(name) @@ -385,6 +397,24 @@ async fn authenticate( Ok(lease_expires_at) } +fn bearer(headers: &HeaderMap) -> Option<&str> { + headers + .get(header::AUTHORIZATION)? + .to_str() + .ok()? + .strip_prefix("Bearer ") +} + +/// Compares without an early exit on the first differing byte. Length still +/// leaks, which says nothing useful about a random key. +fn constant_time_eq(a: &str, b: &str) -> bool { + a.len() == b.len() + && a.bytes() + .zip(b.bytes()) + .fold(0, |diff, (x, y)| diff | (x ^ y)) + == 0 +} + async fn hash(token: &str) -> Result { let token = token.to_owned(); Ok( diff --git a/src/main.rs b/src/main.rs index 02d70f8..076edfe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,13 @@ async fn main() -> Result<(), Box> { apex: required("APEX_DOMAIN")?, mints_per_hour: optional("MINTS_PER_HOUR")?.unwrap_or(30), client_ip_header: optional("CLIENT_IP_HEADER")?, + mint_keys: std::env::var("MINT_KEYS") + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(str::to_owned) + .collect(), }; let state = Arc::new(AppState::new(db, zone, ca, config)); diff --git a/tests/api.rs b/tests/api.rs index 2033ed0..93a9934 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -22,6 +22,7 @@ use testcontainers_modules::testcontainers::runners::AsyncRunner; use tower::ServiceExt; const APEX: &str = "ployz.test"; +const MINT_KEY: &str = "test-mint-key"; type Sets = HashMap<(String, RecordType), RecordSet>; @@ -176,6 +177,7 @@ async fn harness(mints_per_hour: u32) -> Harness { apex: APEX.into(), mints_per_hour, client_ip_header: Some("x-real-ip".parse().unwrap()), + mint_keys: vec!["other-key".into(), MINT_KEY.into()], }; let state = Arc::new(AppState::new(db.clone(), zone.clone(), ca.clone(), config)); let app = router(Arc::clone(&state)) @@ -312,6 +314,35 @@ async fn mint_is_rate_limited_per_source_ip() { } } +#[tokio::test] +async fn mint_key_skips_the_limit_and_a_wrong_key_is_refused() { + let h = &harness(1).await; + let mint_with = |authorization: Option<&'static str>| { + let headers: Vec<(&str, &str)> = authorization + .map(|value| ("authorization", value)) + .into_iter() + .collect(); + async move { + h.call(Method::POST, "/domains", &headers, Some(json!({}))) + .await + } + }; + let key = "Bearer test-mint-key"; + + // No header: anonymous and limited. + assert_eq!(mint_with(None).await.0, StatusCode::CREATED); + assert_eq!(mint_with(None).await.0, StatusCode::TOO_MANY_REQUESTS); + // A known key bypasses the limit from the same address. + assert_eq!(mint_with(Some(key)).await.0, StatusCode::CREATED); + assert_eq!(mint_with(Some(key)).await.0, StatusCode::CREATED); + // A wrong or malformed key is refused, not treated as anonymous. + for wrong in ["Bearer test-mint-kez", "Bearer ", "test-mint-key"] { + let (status, body) = mint_with(Some(wrong)).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{wrong}"); + assert_eq!(body["error"], "unauthorized"); + } +} + #[tokio::test] async fn rotate_kills_the_old_token_at_once() { let h = harness(100).await; From 9876d308d23669bf27ed73447ca6cd4706e4b7f0 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 25 Sep 2026 04:36:11 +0000 Subject: [PATCH 4/7] Run fmt, clippy and tests in CI --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..863f75b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --check + - run: cargo clippy --all-targets -- -D warnings + # Tests start Postgres through Docker (testcontainers); ubuntu-latest has it. + - run: cargo test From d4c53d5ec791a0be01452b300a4d1a193231324a Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 25 Sep 2026 04:58:52 +0000 Subject: [PATCH 5/7] Address review round 0 - The reaper frees only reservations that never published records and never asked for a certificate; a name that may hold a valid wildcard is only ever retired, so it can not be reissued. - ClusterDomain and Token newtypes own the derived names, token generation and bcrypt hashing. - cert::Issuer owns the CA backoff and returns CertError; the handler is authorize, mark used, issue. - One RecordType to RrType mapping; TXT is never read back. - The wildcard CNAME is always upserted. - JsonRejection converts into ApiError; IntoResponse is one match. - authenticate is now authorize_and_renew. - Error-path test for the reaper; documented the API contract. --- README.md | 18 +++ migrations/0001_domains.sql | 4 +- src/acme.rs | 44 +++--- src/api.rs | 296 +++++++++++++++++++----------------- src/cert.rs | 95 +++++++++--- src/domain.rs | 143 +++++++++++++++++ src/label.rs | 65 -------- src/lib.rs | 5 +- src/main.rs | 7 +- src/route53.rs | 33 ++-- src/zone.rs | 10 +- tests/api.rs | 62 +++++++- 12 files changed, 511 insertions(+), 271 deletions(-) create mode 100644 src/domain.rs delete mode 100644 src/label.rs diff --git a/README.md b/README.md index 9c8d472..7781c39 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,21 @@ environment; see `.env.example`. Migrations run at startup. Tests need Docker: `cargo test` starts a throwaway Postgres per test (testcontainers) and runs the HTTP API against an in-memory fake Route 53 and a fake ACME CA that validates DNS-01 through it. + +## API + +``` +GET /healthz anon 200 "ok" +POST /domains anon* {preferred?} → 201 {name, token} +POST /domains/{name}/rotate bearer → 200 {token} +PUT /domains/{name}/records bearer {a?, aaaa?} → 204 +POST /domains/{name}/lease bearer → 200 {name, lease_expires_at} +POST /domains/{name}/certificate bearer {csr} → 200 {certificate_chain_pem} +DELETE /domains/{name} bearer → 204, name retired forever +``` + +- Anonymous mints are limited per source IP. A `MINT_KEYS` key sent as the bearer skips the limit, and a wrong key gets 401. +- Every authorized call renews a 7-day lease. +- `PUT /records` replaces the whole apex set. It returns 422 `invalid_records` for an empty set, a non-public address, or more than 32 addresses per family. +- A name that has published no records and asked for no certificate after 24 hours is freed. Any other name is retired forever, either on `DELETE` or when its lease expires. +- Errors are `{"error": code, "message": text}`. diff --git a/migrations/0001_domains.sql b/migrations/0001_domains.sql index 6b87e3a..2bc515e 100644 --- a/migrations/0001_domains.sql +++ b/migrations/0001_domains.sql @@ -5,6 +5,8 @@ CREATE TABLE domains ( token_hash TEXT NOT NULL, reserved_at TIMESTAMPTZ NOT NULL DEFAULT now(), lease_expires_at TIMESTAMPTZ NOT NULL, - has_records BOOLEAN NOT NULL DEFAULT false, + -- Set once records were published or a certificate was requested. Unused + -- reservations are freed after 24 hours; used names only ever retire. + used BOOLEAN NOT NULL DEFAULT false, retired_at TIMESTAMPTZ ); diff --git a/src/acme.rs b/src/acme.rs index 15f7f31..28de3b9 100644 --- a/src/acme.rs +++ b/src/acme.rs @@ -9,9 +9,9 @@ use bytes::Bytes; use hyper_util::client::legacy::Client as HyperClient; use hyper_util::rt::TokioExecutor; use instant_acme::{ - Account, AccountCredentials, AuthorizationStatus, BodyWrapper, BytesResponse, ChallengeType, - ExternalAccountKey, HttpClient, Identifier, NewAccount, NewOrder, Order, OrderStatus, - RetryPolicy, + Account, AccountCredentials, AuthorizationHandle, AuthorizationStatus, BodyWrapper, + BytesResponse, ChallengeHandle, ChallengeType, ExternalAccountKey, HttpClient, Identifier, + NewAccount, NewOrder, Order, OrderStatus, RetryPolicy, }; use sqlx::PgPool; use tokio::sync::OnceCell; @@ -19,7 +19,7 @@ use tokio::sync::OnceCell; use crate::cert::{Ca, CaError}; /// Order validation and certificate download get this long. -const POLL: RetryPolicy = RetryPolicy::new() +const ORDER_POLL_POLICY: RetryPolicy = RetryPolicy::new() .initial_delay(Duration::from_secs(2)) .backoff(1.5) .timeout(Duration::from_secs(300)); @@ -28,7 +28,9 @@ const DEFAULT_BACKOFF: Duration = Duration::from_secs(3600); /// Where the ACME account lives and how to create it. pub struct AcmeConfig { + /// ACME directory, e.g. Google Trust Services production or staging. pub directory_url: String, + /// External Account Binding key id. pub eab_kid: String, /// Raw HMAC key bytes (the CA hands them out base64url-encoded). pub eab_hmac: Vec, @@ -65,10 +67,10 @@ impl Acme { .bind(url) .fetch_optional(&self.db) .await - .map_err(failed)?; + .map_err(ca_failure)?; if let Some(stored) = stored { let credentials: AccountCredentials = - serde_json::from_str(&stored).map_err(failed)?; + serde_json::from_str(&stored).map_err(ca_failure)?; return builder .from_credentials(credentials) .await @@ -89,10 +91,10 @@ impl Acme { "INSERT INTO acme_accounts (directory_url, credentials) VALUES ($1, $2)", ) .bind(url) - .bind(serde_json::to_string(&credentials).map_err(failed)?) + .bind(serde_json::to_string(&credentials).map_err(ca_failure)?) .execute(&self.db) .await - .map_err(failed)?; + .map_err(ca_failure)?; tracing::info!(id = account.id(), "created ACME account"); Ok(account) }) @@ -102,7 +104,7 @@ impl Acme { fn http(&self) -> Result { let https = hyper_rustls::HttpsConnectorBuilder::new() .try_with_platform_verifier() - .map_err(failed)? + .map_err(ca_failure)? .https_only() .enable_http1() .enable_http2() @@ -147,9 +149,7 @@ impl Ca for Acme { match authorization.status { AuthorizationStatus::Valid => {} AuthorizationStatus::Pending => { - let challenge = authorization - .challenge(ChallengeType::Dns01) - .ok_or_else(|| CaError::Failed("CA offered no dns-01 challenge".into()))?; + let challenge = dns01(&mut authorization)?; values.push(challenge.key_authorization().dns_value()); } status => return Err(CaError::Failed(format!("authorization is {status:?}"))), @@ -165,16 +165,14 @@ impl Ca for Acme { if authorization.status != AuthorizationStatus::Pending { continue; } - let mut challenge = authorization - .challenge(ChallengeType::Dns01) - .ok_or_else(|| CaError::Failed("CA offered no dns-01 challenge".into()))?; + let mut challenge = dns01(&mut authorization)?; challenge .set_ready() .await .map_err(|error| self.ca_error(error))?; } let status = order - .poll_ready(&POLL) + .poll_ready(&ORDER_POLL_POLICY) .await .map_err(|error| self.ca_error(error))?; if status != OrderStatus::Ready { @@ -187,7 +185,7 @@ impl Ca for Acme { .await .map_err(|error| self.ca_error(error))?; order - .poll_certificate(&POLL) + .poll_certificate(&ORDER_POLL_POLICY) .await .map_err(|error| self.ca_error(error)) } @@ -199,6 +197,8 @@ type HttpsClient = HyperClient< >; /// instant-acme drops `Retry-After` from 429 problems, so record it on the way through. +// ponytail: one slot for all requests; two 429s racing may pair a problem with the +// other's Retry-After. Both come from the same account quota, so either is close. struct RetryAfterRecorder { inner: HttpsClient, until: Arc>>, @@ -222,6 +222,14 @@ impl HttpClient for RetryAfterRecorder { } } +fn dns01<'a>( + authorization: &'a mut AuthorizationHandle<'a>, +) -> Result, CaError> { + authorization + .challenge(ChallengeType::Dns01) + .ok_or_else(|| CaError::Failed("CA offered no dns-01 challenge".into())) +} + /// `Retry-After` as delay-seconds or an HTTP date. fn retry_after(headers: &HeaderMap) -> Option { let value = headers.get(header::RETRY_AFTER)?.to_str().ok()?.trim(); @@ -231,6 +239,6 @@ fn retry_after(headers: &HeaderMap) -> Option { } } -fn failed(error: impl std::fmt::Display) -> CaError { +fn ca_failure(error: impl std::fmt::Display) -> CaError { CaError::Failed(error.to_string()) } diff --git a/src/api.rs b/src/api.rs index f4ac149..df359ce 100644 --- a/src/api.rs +++ b/src/api.rs @@ -15,15 +15,12 @@ use chrono::{DateTime, TimeDelta, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{PgConnection, PgPool}; -use crate::cert::{self, Ca, CaError}; -use crate::label; +use crate::cert::{Ca, CaError, CertError, Issuer}; +use crate::domain::{self, ClusterDomain, Token}; use crate::zone::{Change, RecordSet, RecordType, Zone, ZoneError}; const LEASE: TimeDelta = TimeDelta::days(7); const UNUSED_RESERVATION: TimeDelta = TimeDelta::hours(24); -const TOKEN_LEN: usize = 40; -// Tokens are 200+ random bits, so bcrypt's work factor guards nothing brute force could reach. -const BCRYPT_COST: u32 = 10; const MINT_ATTEMPTS: usize = 8; const RECORD_TTL: i64 = 60; const MAX_ADDRESSES: usize = 32; @@ -45,14 +42,13 @@ pub struct Config { pub struct AppState { db: PgPool, zone: Z, - ca: C, + issuer: Issuer, config: Config, mints: MintLimiter, - /// The CA's quota is per account, so one 429 pauses every caller. - ca_blocked_until: Mutex>, } impl AppState { + /// Wires the database, the DNS zone, the certificate authority and settings. pub fn new(db: PgPool, zone: Z, ca: C, config: Config) -> Self { let mints = MintLimiter { per_hour: config.mints_per_hour, @@ -61,15 +57,14 @@ impl AppState { Self { db, zone, - ca, + issuer: Issuer::new(ca), config, mints, - ca_blocked_until: Mutex::default(), } } } -/// The service's routes. +/// The service's routes, including `GET /healthz` for the platform's probe. pub fn router(state: Arc>) -> Router { Router::new() .route("/healthz", get(|| async { "ok" })) @@ -89,8 +84,8 @@ struct MintRequest { #[derive(Serialize)] struct MintResponse { - name: String, - token: String, + name: ClusterDomain, + token: Token, } async fn mint( @@ -99,7 +94,7 @@ async fn mint( headers: HeaderMap, body: Result>, JsonRejection>, ) -> Result<(StatusCode, Json), ApiError> { - let body = body.map_err(|rejection| ApiError::BadRequest(rejection.body_text()))?; + let body = body?; if headers.contains_key(header::AUTHORIZATION) { // A caller that presents a key never falls back to anonymous minting. let key = bearer(&headers).ok_or(ApiError::Unauthorized)?; @@ -118,10 +113,10 @@ async fn mint( state.mints.check(client)?; } let preferred = body.and_then(|Json(body)| body.preferred); - let token = label::random_string(TOKEN_LEN); - let token_hash = hash(&token).await?; - for label in label::candidates(preferred.as_deref()).take(MINT_ATTEMPTS) { - let name = format!("{label}.{}", state.config.apex); + let token = Token::generate(); + let token_hash = token.hash().await?; + for label in domain::candidates(preferred.as_deref()).take(MINT_ATTEMPTS) { + let name = ClusterDomain::new(&label, &state.config.apex); // Retired names keep their row, so the conflict also refuses them. let inserted = sqlx::query( "INSERT INTO domains (name, token_hash, lease_expires_at) VALUES ($1, $2, now() + $3) @@ -143,20 +138,20 @@ async fn mint( #[derive(Serialize)] struct RotateResponse { - token: String, + token: Token, } async fn rotate( State(state): State>>, - Path(name): Path, + Path(name): Path, headers: HeaderMap, ) -> Result, ApiError> { let mut tx = state.db.begin().await?; - authenticate(&mut tx, &name, &headers).await?; - let token = label::random_string(TOKEN_LEN); + authorize_and_renew(&mut tx, &name, &headers).await?; + let token = Token::generate(); sqlx::query("UPDATE domains SET token_hash = $2 WHERE name = $1") .bind(&name) - .bind(hash(&token).await?) + .bind(token.hash().await?) .execute(&mut *tx) .await?; tx.commit().await?; @@ -165,17 +160,17 @@ async fn rotate( #[derive(Serialize)] struct LeaseResponse { - name: String, + name: ClusterDomain, lease_expires_at: DateTime, } async fn renew( State(state): State>>, - Path(name): Path, + Path(name): Path, headers: HeaderMap, ) -> Result, ApiError> { let mut tx = state.db.begin().await?; - let lease_expires_at = authenticate(&mut tx, &name, &headers).await?; + let lease_expires_at = authorize_and_renew(&mut tx, &name, &headers).await?; tx.commit().await?; Ok(Json(LeaseResponse { name, @@ -183,9 +178,10 @@ async fn renew( })) } +/// The full apex address set; an omitted family is removed. #[derive(Deserialize)] #[serde(deny_unknown_fields)] -struct Records { +struct ApexAddresses { #[serde(default)] a: Vec, #[serde(default)] @@ -194,69 +190,64 @@ struct Records { async fn put_records( State(state): State>>, - Path(name): Path, + Path(name): Path, headers: HeaderMap, - body: Result, JsonRejection>, + body: Result, JsonRejection>, ) -> Result { - let Json(records) = body.map_err(|rejection| ApiError::BadRequest(rejection.body_text()))?; + let Json(addresses) = body?; let mut tx = state.db.begin().await?; - authenticate(&mut tx, &name, &headers).await?; - let wanted = [ - (RecordType::A, addresses(records.a, |ip| is_public_v4(*ip))?), - (RecordType::Aaaa, addresses(records.aaaa, is_public_v6)?), + authorize_and_renew(&mut tx, &name, &headers).await?; + let desired_apex = [ + ( + RecordType::A, + public_addresses(addresses.a, |ip| is_public_v4(*ip))?, + ), + ( + RecordType::Aaaa, + public_addresses(addresses.aaaa, is_public_v6)?, + ), ]; - if wanted.iter().all(|(_, values)| values.is_empty()) { + if desired_apex.iter().all(|(_, values)| values.is_empty()) { return Err(ApiError::InvalidRecords( "at least one address is required".into(), )); } - let current = state.zone.record_sets(&name).await?; + let current_apex = state.zone.record_sets(name.as_str()).await?; let mut changes = Vec::new(); - for (kind, values) in wanted { + for (kind, values) in desired_apex { if !values.is_empty() { changes.push(Change::Upsert(RecordSet { - name: name.clone(), + name: name.as_str().to_owned(), kind, ttl: RECORD_TTL, values, })); - } else if let Some(stale) = current.iter().find(|set| set.kind == kind) { + } else if let Some(stale) = current_apex.iter().find(|set| set.kind == kind) { changes.push(Change::Delete(stale.clone())); } } - let wildcard = format!("*.{name}"); - let has_wildcard = state - .zone - .record_sets(&wildcard) - .await? - .iter() - .any(|set| set.kind == RecordType::Cname); - if !has_wildcard { - changes.push(Change::Upsert(RecordSet { - values: vec![name.clone()], - name: wildcard, - kind: RecordType::Cname, - ttl: RECORD_TTL, - })); - } + // Upserting an unchanged CNAME is a no-op, so no read is needed to write it "once". + changes.push(Change::Upsert(RecordSet { + name: name.wildcard(), + kind: RecordType::Cname, + ttl: RECORD_TTL, + values: vec![name.as_str().to_owned()], + })); state.zone.apply(changes).await?; - sqlx::query("UPDATE domains SET has_records = true WHERE name = $1") - .bind(&name) - .execute(&mut *tx) - .await?; + mark_used(&mut tx, &name).await?; tx.commit().await?; Ok(StatusCode::NO_CONTENT) } async fn release( State(state): State>>, - Path(name): Path, + Path(name): Path, headers: HeaderMap, ) -> Result { let mut tx = state.db.begin().await?; - authenticate(&mut tx, &name, &headers).await?; + authorize_and_renew(&mut tx, &name, &headers).await?; retire(&state.zone, &mut tx, &name).await?; tx.commit().await?; Ok(StatusCode::NO_CONTENT) @@ -276,40 +267,25 @@ struct CertificateResponse { // challenge and one fails. Cloud runs one sync per Organization at a time. async fn certificate( State(state): State>>, - Path(name): Path, + Path(name): Path, headers: HeaderMap, body: Result, JsonRejection>, ) -> Result, ApiError> { - let Json(request) = body.map_err(|rejection| ApiError::BadRequest(rejection.body_text()))?; + let Json(request) = body?; let mut tx = state.db.begin().await?; - authenticate(&mut tx, &name, &headers).await?; + authorize_and_renew(&mut tx, &name, &headers).await?; + // A certificate may exist from here on, so the reaper must never free this name. + mark_used(&mut tx, &name).await?; // Issuance takes minutes; do not hold the row lock through it. tx.commit().await?; - let csr_der = cert::csr_der(&request.csr, &name)?; - - let blocked_until = *state - .ca_blocked_until - .lock() - .expect("ca backoff lock is never poisoned"); - if let Some(until) = blocked_until - && until > SystemTime::now() - { - return Err(CaError::RateLimited { until }.into()); - } - let issued = cert::issue(&state.zone, &state.ca, &name, &csr_der).await; - if let Err(ApiError::Ca(CaError::RateLimited { until })) = &issued { - *state - .ca_blocked_until - .lock() - .expect("ca backoff lock is never poisoned") = Some(*until); - } + let certificate_chain_pem = state.issuer.issue(&state.zone, &name, &request.csr).await?; Ok(Json(CertificateResponse { - certificate_chain_pem: issued?, + certificate_chain_pem, })) } -/// Frees reservations that published no records within 24 hours, and retires -/// names whose lease expired (removing their records). +/// Frees reservations that never published records or asked for a certificate +/// within 24 hours, and retires names whose lease expired (removing their records). /// /// # Errors /// @@ -317,7 +293,7 @@ async fn certificate( pub async fn reap(state: &AppState) -> Result<(), ApiError> { sqlx::query( "DELETE FROM domains - WHERE retired_at IS NULL AND NOT has_records AND reserved_at < now() - $1", + WHERE retired_at IS NULL AND NOT used AND reserved_at < now() - $1", ) .bind(UNUSED_RESERVATION) .execute(&state.db) @@ -325,7 +301,7 @@ pub async fn reap(state: &AppState) -> Result<(), ApiError // ponytail: one name per transaction; a name Route 53 keeps refusing stalls the rest until fixed. loop { let mut tx = state.db.begin().await?; - let Some(name) = sqlx::query_scalar::<_, String>( + let Some(name) = sqlx::query_scalar::<_, ClusterDomain>( "SELECT name FROM domains WHERE retired_at IS NULL AND lease_expires_at < now() LIMIT 1 FOR UPDATE SKIP LOCKED", ) @@ -340,10 +316,23 @@ pub async fn reap(state: &AppState) -> Result<(), ApiError } } +/// Once records or a certificate exist, only the lease decides the name's fate. +async fn mark_used(tx: &mut PgConnection, name: &ClusterDomain) -> Result<(), ApiError> { + sqlx::query("UPDATE domains SET used = true WHERE name = $1") + .bind(name) + .execute(tx) + .await?; + Ok(()) +} + /// Removes the apex and wildcard records and marks the name retired forever. -async fn retire(zone: &Z, tx: &mut PgConnection, name: &str) -> Result<(), ApiError> { +async fn retire( + zone: &Z, + tx: &mut PgConnection, + name: &ClusterDomain, +) -> Result<(), ApiError> { let mut changes = Vec::new(); - for set_name in [name.to_owned(), format!("*.{name}")] { + for set_name in [name.as_str().to_owned(), name.wildcard()] { changes.extend( zone.record_sets(&set_name) .await? @@ -361,14 +350,15 @@ async fn retire(zone: &Z, tx: &mut PgConnection, name: &str) -> Result< Ok(()) } -/// Locks the name's row for the transaction, checks the bearer token and renews -/// the lease. Returns the new lease expiry. -async fn authenticate( +/// Locks the name's row for the transaction, checks the bearer token, and +/// renews the lease: every authorized call keeps the name alive. Returns the +/// new lease expiry. +async fn authorize_and_renew( tx: &mut PgConnection, - name: &str, + name: &ClusterDomain, headers: &HeaderMap, ) -> Result, ApiError> { - let token = bearer(headers).ok_or(ApiError::Unauthorized)?.to_owned(); + let token = Token::from_bearer(bearer(headers).ok_or(ApiError::Unauthorized)?); let row: Option<(String, Option>)> = sqlx::query_as("SELECT token_hash, retired_at FROM domains WHERE name = $1 FOR UPDATE") .bind(name) @@ -380,10 +370,7 @@ async fn authenticate( if retired_at.is_some() { return Err(ApiError::Retired); } - let valid = tokio::task::spawn_blocking(move || bcrypt::verify(token, &token_hash)) - .await - .expect("bcrypt verify does not panic")?; - if !valid { + if !token.verify(token_hash).await? { return Err(ApiError::Unauthorized); } let lease_expires_at = sqlx::query_scalar( @@ -415,17 +402,9 @@ fn constant_time_eq(a: &str, b: &str) -> bool { == 0 } -async fn hash(token: &str) -> Result { - let token = token.to_owned(); - Ok( - tokio::task::spawn_blocking(move || bcrypt::hash(token, BCRYPT_COST)) - .await - .expect("bcrypt hash does not panic")?, - ) -} - -/// Sorts and dedups `addresses`, refusing non-public ones and oversized sets. -fn addresses( +/// Sorts and dedups `addresses` into record values, refusing non-public +/// addresses and sets larger than [`MAX_ADDRESSES`]. +fn public_addresses( mut addresses: Vec, is_public: impl Fn(&T) -> bool, ) -> Result, ApiError> { @@ -504,68 +483,107 @@ impl MintLimiter { /// Every failure the API returns, rendered as `{"error": code, "message": text}`. #[derive(Debug, thiserror::Error)] pub enum ApiError { + /// Malformed JSON, bad address syntax, or unknown fields: 400. #[error("{0}")] BadRequest(String), + /// Empty, non-public or oversized address set: 422. #[error("{0}")] InvalidRecords(String), - #[error("{0}")] - InvalidCsr(String), + /// Missing or wrong bearer token or mint key: 401. #[error("missing or wrong bearer token")] Unauthorized, + /// Never minted, or reaped: 404. #[error("no such domain")] NotFound, + /// Released or lease expired: 410. #[error("domain was released and is retired")] Retired, + /// Anonymous mint limit hit: 429 with `Retry-After`. #[error("too many domains minted from this address")] RateLimited { retry_after_secs: u64 }, + /// Every label attempt collided: 503. #[error("no free label found, try again")] NamespaceExhausted, + /// Certificate request failed: 422, 429 or 502. + #[error(transparent)] + Cert(#[from] CertError), + /// Route 53 failed: 502. #[error("dns provider: {0}")] Zone(#[from] ZoneError), - #[error("certificate authority: {0}")] - Ca(#[from] CaError), + /// Postgres failed: 500. #[error("database: {0}")] Db(#[from] sqlx::Error), + /// Token hashing failed: 500. #[error("token hashing: {0}")] Hash(#[from] bcrypt::BcryptError), } +impl From for ApiError { + fn from(rejection: JsonRejection) -> Self { + Self::BadRequest(rejection.body_text()) + } +} + impl IntoResponse for ApiError { fn into_response(self) -> Response { - let (status, code) = match &self { - Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"), - Self::InvalidRecords(_) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_records"), - Self::InvalidCsr(_) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_csr"), - Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"), - Self::NotFound => (StatusCode::NOT_FOUND, "not_found"), - Self::Retired => (StatusCode::GONE, "retired"), - Self::RateLimited { .. } => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"), - Self::NamespaceExhausted => (StatusCode::SERVICE_UNAVAILABLE, "namespace_exhausted"), - Self::Zone(_) => (StatusCode::BAD_GATEWAY, "dns_provider_error"), - Self::Ca(CaError::RateLimited { .. }) => { - (StatusCode::TOO_MANY_REQUESTS, "ca_rate_limited") + let seconds_until = |until: &SystemTime| { + until + .duration_since(SystemTime::now()) + .map_or(1, |left| left.as_secs().max(1)) + }; + // AWS errors can carry account ids, so upstream DNS and internal + // failures are logged, not returned. CA problems are safe and Cloud + // needs the reason. + let (status, code, retry_after_secs, public) = match &self { + Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request", None, true), + Self::InvalidRecords(_) => ( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid_records", + None, + true, + ), + Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized", None, true), + Self::NotFound => (StatusCode::NOT_FOUND, "not_found", None, true), + Self::Retired => (StatusCode::GONE, "retired", None, true), + Self::RateLimited { retry_after_secs } => ( + StatusCode::TOO_MANY_REQUESTS, + "rate_limited", + Some(*retry_after_secs), + true, + ), + Self::NamespaceExhausted => ( + StatusCode::SERVICE_UNAVAILABLE, + "namespace_exhausted", + None, + true, + ), + Self::Cert(CertError::InvalidCsr(_)) => { + (StatusCode::UNPROCESSABLE_ENTITY, "invalid_csr", None, true) + } + Self::Cert(CertError::Ca(CaError::RateLimited { until })) => ( + StatusCode::TOO_MANY_REQUESTS, + "ca_rate_limited", + Some(seconds_until(until)), + true, + ), + Self::Cert(CertError::Ca(CaError::Failed(_))) => { + (StatusCode::BAD_GATEWAY, "ca_error", None, true) + } + Self::Cert(CertError::Zone(_)) | Self::Zone(_) => { + (StatusCode::BAD_GATEWAY, "dns_provider_error", None, false) + } + Self::Db(_) | Self::Hash(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, "internal", None, false) } - Self::Ca(CaError::Failed(_)) => (StatusCode::BAD_GATEWAY, "ca_error"), - Self::Db(_) | Self::Hash(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal"), }; - // AWS errors can carry account ids; CA problems are safe and Cloud needs the reason. - let message = if matches!(self, Self::Zone(_) | Self::Db(_) | Self::Hash(_)) { + let message = if public { + self.to_string() + } else { tracing::error!(error = %self, "request failed"); "internal error".to_owned() - } else { - self.to_string() }; let body = Json(serde_json::json!({ "error": code, "message": message })); let mut response = (status, body).into_response(); - let retry_after_secs = match self { - Self::RateLimited { retry_after_secs } => Some(retry_after_secs), - Self::Ca(CaError::RateLimited { until }) => Some( - until - .duration_since(SystemTime::now()) - .map_or(1, |left| left.as_secs().max(1)), - ), - _ => None, - }; if let Some(secs) = retry_after_secs { response .headers_mut() diff --git a/src/cert.rs b/src/cert.rs index a5b5245..27f6fe0 100644 --- a/src/cert.rs +++ b/src/cert.rs @@ -1,14 +1,15 @@ //! Wildcard certificates: CSR scope check and DNS-01 issuance through the zone. use std::collections::BTreeSet; +use std::sync::Mutex; use std::time::SystemTime; use x509_parser::certification_request::X509CertificationRequest; use x509_parser::extensions::{GeneralName, ParsedExtension}; use x509_parser::prelude::FromDer; -use crate::api::ApiError; -use crate::zone::{Change, RecordSet, RecordType, Zone}; +use crate::domain::ClusterDomain; +use crate::zone::{Change, RecordSet, RecordType, Zone, ZoneError}; const CHALLENGE_TTL: i64 = 60; @@ -49,17 +50,76 @@ pub enum CaError { /// The CA answered 429; no call may reach it before `until`. #[error("certificate authority rate limit")] RateLimited { until: SystemTime }, + /// Any other refusal or failure, with the CA's explanation. #[error("{0}")] Failed(String), } -/// Checks that `pem` is a CSR naming exactly `name` and `*.name`, and returns its DER. -/// -/// # Errors -/// -/// Returns [`ApiError::InvalidCsr`] for anything else. -pub(crate) fn csr_der(pem: &str, name: &str) -> Result, ApiError> { - let invalid = |why: &str| ApiError::InvalidCsr(why.to_owned()); +/// Why a certificate request failed. +#[derive(Debug, thiserror::Error)] +pub enum CertError { + /// The CSR is not PEM PKCS#10 naming exactly `name` and `*.name`. + #[error("{0}")] + InvalidCsr(String), + #[error("certificate authority: {0}")] + Ca(#[from] CaError), + #[error("dns provider: {0}")] + Zone(#[from] ZoneError), +} + +/// Issues certificates through one CA account and owns its backoff: the CA's +/// quota is per account, so one 429 pauses every name until `Retry-After`. +pub(crate) struct Issuer { + ca: C, + blocked_until: Mutex>, +} + +impl Issuer { + pub(crate) fn new(ca: C) -> Self { + Self { + ca, + blocked_until: Mutex::default(), + } + } + + /// Checks `csr_pem`, orders `name` + `*.name`, publishes the DNS-01 TXT + /// record, waits for the zone to sync, finalises and returns the PEM chain. + /// The TXT record is removed whether or not issuance succeeds. + /// + /// # Errors + /// + /// Returns [`CertError`] for a bad CSR, an active or new CA rate limit, or + /// a CA or zone failure. + pub(crate) async fn issue( + &self, + zone: &Z, + name: &ClusterDomain, + csr_pem: &str, + ) -> Result { + let csr_der = csr_der(csr_pem, name)?; + let blocked_until = *self + .blocked_until + .lock() + .expect("backoff lock is never poisoned"); + if let Some(until) = blocked_until + && until > SystemTime::now() + { + return Err(CaError::RateLimited { until }.into()); + } + let issued = order_with_dns01(zone, &self.ca, name, &csr_der).await; + if let Err(CertError::Ca(CaError::RateLimited { until })) = &issued { + *self + .blocked_until + .lock() + .expect("backoff lock is never poisoned") = Some(*until); + } + issued + } +} + +/// Returns the DER of `pem` if it is a CSR naming exactly `name` and `*.name`. +fn csr_der(pem: &str, name: &ClusterDomain) -> Result, CertError> { + let invalid = |why: &str| CertError::InvalidCsr(why.to_owned()); let (_, pem) = x509_parser::pem::parse_x509_pem(pem.as_bytes()).map_err(|_| invalid("csr is not PEM"))?; if pem.label != "CERTIFICATE REQUEST" { @@ -68,7 +128,7 @@ pub(crate) fn csr_der(pem: &str, name: &str) -> Result, ApiError> { let (_, csr) = X509CertificationRequest::from_der(&pem.contents) .map_err(|_| invalid("csr is not a valid PKCS#10 request"))?; - let expected = BTreeSet::from([name.to_owned(), format!("*.{name}")]); + let expected = BTreeSet::from([name.as_str().to_owned(), name.wildcard()]); let mut requested = BTreeSet::new(); for extension in csr.requested_extensions().into_iter().flatten() { let ParsedExtension::SubjectAlternativeName(san) = extension else { @@ -87,30 +147,27 @@ pub(crate) fn csr_der(pem: &str, name: &str) -> Result, ApiError> { .iter_common_name() .all(|cn| cn.as_str().is_ok_and(|cn| expected.contains(cn))); if requested != expected || !common_names_ok { - return Err(ApiError::InvalidCsr(format!( + return Err(CertError::InvalidCsr(format!( "csr must name exactly {name} and *.{name}" ))); } Ok(pem.contents) } -/// Orders `name` + `*.name`, publishes the DNS-01 TXT record, waits for Route 53 -/// to sync, finalises with `csr_der` and returns the chain. The TXT record is -/// removed whether or not issuance succeeds. -pub(crate) async fn issue( +async fn order_with_dns01( zone: &Z, ca: &C, - name: &str, + name: &ClusterDomain, csr_der: &[u8], -) -> Result { - let names = [name.to_owned(), format!("*.{name}")]; +) -> Result { + let names = [name.as_str().to_owned(), name.wildcard()]; let (order, values) = ca.new_order(&names).await?; if values.is_empty() { return Ok(ca.finalize(order, csr_der).await?); } // Both authorizations validate at the same name, so one set holds both values. let challenge = RecordSet { - name: format!("_acme-challenge.{name}"), + name: name.challenge_name(), kind: RecordType::Txt, ttl: CHALLENGE_TTL, values, diff --git a/src/domain.rs b/src/domain.rs new file mode 100644 index 0000000..36da53b --- /dev/null +++ b/src/domain.rs @@ -0,0 +1,143 @@ +//! Cluster Domain names, their bearer tokens, and label choice for new names. + +use bcrypt::BcryptError; +use rand::Rng; +use serde::{Deserialize, Serialize}; + +/// Labels that are never granted verbatim; they get a suffix instead. +const RESERVED: &[&str] = &[ + "www", + "api", + "dns", + "mail", + "admin", + "cloud", + "app", + "ployz", + "ns", + "ns1", + "ns2", + "smtp", + "imap", + "status", + "docs", + "dashboard", + "support", + "root", + "autoconfig", + "autodiscover", +]; + +const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; +/// Length of a bearer token: 40 characters of `[a-z0-9]`, about 206 random bits. +pub const TOKEN_LEN: usize = 40; +// Tokens are 200+ random bits, so bcrypt's work factor guards nothing brute force could reach. +const BCRYPT_COST: u32 = 10; +const SUFFIX_LEN: usize = 4; +const RANDOM_LABEL_LEN: usize = 10; + +/// Labels to try, best first: the preferred label if valid and not reserved, +/// then endless `preferred-xxxx` candidates, or endless random labels when the +/// preferred label is missing or invalid. +pub(crate) fn candidates(preferred: Option<&str>) -> impl Iterator + '_ { + let base = preferred.filter(|label| is_valid(label)); + let exact = base + .filter(|label| !RESERVED.contains(label)) + .map(str::to_owned); + exact + .into_iter() + .chain(std::iter::repeat_with(move || match base { + Some(base) => format!("{base}-{}", random_string(SUFFIX_LEN)), + None => random_string(RANDOM_LABEL_LEN), + })) +} + +/// `[a-z0-9-]{3,40}` with no hyphen at either edge. +fn is_valid(label: &str) -> bool { + (3..=40).contains(&label.len()) + && label + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && !label.starts_with('-') + && !label.ends_with('-') +} + +/// Random `[a-z0-9]` string. +fn random_string(len: usize) -> String { + let mut rng = rand::rng(); + (0..len) + .map(|_| char::from(ALPHABET[rng.random_range(0..ALPHABET.len())])) + .collect() +} + +/// A granted Cluster Domain, e.g. `acme.ployz.app`: one label under the apex. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub(crate) struct ClusterDomain(String); + +impl ClusterDomain { + pub(crate) fn new(label: &str, apex: &str) -> Self { + Self(format!("{label}.{apex}")) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } + + /// `*.name`, the CNAME to the apex and the certificate's second name. + pub(crate) fn wildcard(&self) -> String { + format!("*.{}", self.0) + } + + /// `_acme-challenge.name`, where DNS-01 validates both `name` and `*.name`. + pub(crate) fn challenge_name(&self) -> String { + format!("_acme-challenge.{}", self.0) + } +} + +impl std::fmt::Display for ClusterDomain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// A per-name bearer token. Shown to the caller once; only its bcrypt hash is stored. +#[derive(Serialize)] +#[serde(transparent)] +pub(crate) struct Token(String); + +impl Token { + pub(crate) fn generate() -> Self { + Self(random_string(TOKEN_LEN)) + } + + pub(crate) fn from_bearer(token: &str) -> Self { + Self(token.to_owned()) + } + + /// # Errors + /// + /// Returns [`BcryptError`] when hashing fails. + pub(crate) async fn hash(&self) -> Result { + let token = self.0.clone(); + bcrypt_blocking(move || bcrypt::hash(token, BCRYPT_COST)).await + } + + /// # Errors + /// + /// Returns [`BcryptError`] when `hash` is not a bcrypt hash. + pub(crate) async fn verify(&self, hash: String) -> Result { + let token = self.0.clone(); + bcrypt_blocking(move || bcrypt::verify(token, &hash)).await + } +} + +/// bcrypt is CPU-bound, so it runs off the async workers. +async fn bcrypt_blocking( + work: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tokio::task::spawn_blocking(work) + .await + .expect("bcrypt does not panic") +} diff --git a/src/label.rs b/src/label.rs deleted file mode 100644 index 97691cb..0000000 --- a/src/label.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Label choice for new Cluster Domains. - -use rand::Rng; - -/// Labels that are never granted verbatim; they get a suffix instead. -const RESERVED: &[&str] = &[ - "www", - "api", - "dns", - "mail", - "admin", - "cloud", - "app", - "ployz", - "ns", - "ns1", - "ns2", - "smtp", - "imap", - "status", - "docs", - "dashboard", - "support", - "root", - "autoconfig", - "autodiscover", -]; - -const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; -const SUFFIX_LEN: usize = 4; -const RANDOM_LABEL_LEN: usize = 10; - -/// Labels to try, best first: the preferred label if valid and not reserved, -/// then endless `preferred-xxxx` candidates, or endless random labels when the -/// preferred label is missing or invalid. -pub(crate) fn candidates(preferred: Option<&str>) -> impl Iterator + '_ { - let base = preferred.filter(|label| is_valid(label)); - let exact = base - .filter(|label| !RESERVED.contains(label)) - .map(str::to_owned); - exact - .into_iter() - .chain(std::iter::repeat_with(move || match base { - Some(base) => format!("{base}-{}", random_string(SUFFIX_LEN)), - None => random_string(RANDOM_LABEL_LEN), - })) -} - -/// `[a-z0-9-]{3,40}` with no hyphen at either edge. -fn is_valid(label: &str) -> bool { - (3..=40).contains(&label.len()) - && label - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') - && !label.starts_with('-') - && !label.ends_with('-') -} - -/// Random `[a-z0-9]` string; also used for bearer tokens. -pub(crate) fn random_string(len: usize) -> String { - let mut rng = rand::rng(); - (0..len) - .map(|_| char::from(ALPHABET[rng.random_range(0..ALPHABET.len())])) - .collect() -} diff --git a/src/lib.rs b/src/lib.rs index 8bc84c8..1805747 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,13 +5,14 @@ mod acme; mod api; mod cert; -mod label; +mod domain; mod route53; mod zone; pub use acme::{Acme, AcmeConfig}; pub use api::{ApiError, AppState, Config, reap, router}; -pub use cert::{Ca, CaError}; +pub use cert::{Ca, CaError, CertError}; +pub use domain::TOKEN_LEN; pub use route53::Route53; pub use zone::{Change, ChangeId, RecordSet, RecordType, Zone, ZoneError}; diff --git a/src/main.rs b/src/main.rs index 076edfe..2facf0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use std::time::Duration; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use hosted_dns::{Acme, AcmeConfig, AppState, Config, MIGRATOR, Route53, reap, router}; -use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; use tracing_subscriber::EnvFilter; const REAP_EVERY: Duration = Duration::from_secs(300); @@ -22,10 +22,7 @@ async fn main() -> Result<(), Box> { .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) .init(); - let db = PgPoolOptions::new() - .max_connections(5) - .connect(&required("DATABASE_URL")?) - .await?; + let db = PgPool::connect(&required("DATABASE_URL")?).await?; MIGRATOR.run(&db).await?; let aws = aws_config::load_from_env().await; let zone = Route53::new(&aws, required("HOSTED_ZONE_ID")?); diff --git a/src/route53.rs b/src/route53.rs index c3b32fe..a48b1e8 100644 --- a/src/route53.rs +++ b/src/route53.rs @@ -43,13 +43,10 @@ impl Zone for Route53 { .iter() .filter(|set| normalize(set.name()) == name) .filter_map(|set| { - let kind = match set.r#type() { - r53::RrType::A => RecordType::A, - r53::RrType::Aaaa => RecordType::Aaaa, - r53::RrType::Cname => RecordType::Cname, - r53::RrType::Txt => RecordType::Txt, - _ => return None, - }; + // TXT is write-only here: challenges are deleted with the values we wrote. + let kind = [RecordType::A, RecordType::Aaaa, RecordType::Cname] + .into_iter() + .find(|kind| rr_type(*kind) == *set.r#type())?; Some(RecordSet { name: name.to_owned(), kind, @@ -57,10 +54,7 @@ impl Zone for Route53 { values: set .resource_records() .iter() - .map(|record| match kind { - RecordType::Txt => record.value().trim_matches('"').to_owned(), - _ => record.value().to_owned(), - }) + .map(|record| record.value().to_owned()) .collect(), }) }) @@ -131,15 +125,9 @@ fn to_r53(change: Change) -> Result, _>>()?; - let kind = match set.kind { - RecordType::A => r53::RrType::A, - RecordType::Aaaa => r53::RrType::Aaaa, - RecordType::Cname => r53::RrType::Cname, - RecordType::Txt => r53::RrType::Txt, - }; let set = r53::ResourceRecordSet::builder() + .r#type(rr_type(set.kind)) .name(set.name) - .r#type(kind) .ttl(set.ttl) .set_resource_records(Some(records)) .build()?; @@ -149,6 +137,15 @@ fn to_r53(change: Change) -> Result r53::RrType { + match kind { + RecordType::A => r53::RrType::A, + RecordType::Aaaa => r53::RrType::Aaaa, + RecordType::Cname => r53::RrType::Cname, + RecordType::Txt => r53::RrType::Txt, + } +} + /// Route 53 returns `\052.acme.ployz.app.` for `*.acme.ployz.app`. fn normalize(name: &str) -> String { name.trim_end_matches('.').replace("\\052", "*") diff --git a/src/zone.rs b/src/zone.rs index 9f8d42e..7a28984 100644 --- a/src/zone.rs +++ b/src/zone.rs @@ -3,10 +3,13 @@ /// Record types the service manages. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum RecordType { + /// Apex IPv4 addresses. A, + /// Apex IPv6 addresses. Aaaa, + /// `*.name` pointing at the apex. Cname, - /// Unquoted values; the provider adds and strips the quotes. + /// DNS-01 challenge values, unquoted; the provider adds the quotes. Txt, } @@ -15,15 +18,20 @@ pub enum RecordType { pub struct RecordSet { /// Fully qualified name without the trailing dot, e.g. `*.acme.ployz.app`. pub name: String, + /// The record type. pub kind: RecordType, + /// Time to live in seconds. pub ttl: i64, + /// Record values, e.g. addresses or the CNAME target. pub values: Vec, } /// A change with Route 53 semantics: `Delete` must match the existing set exactly. #[derive(Clone, Debug)] pub enum Change { + /// Create or replace the set. Upsert(RecordSet), + /// Remove the set; it must match what the zone holds. Delete(RecordSet), } diff --git a/tests/api.rs b/tests/api.rs index 93a9934..e7880cc 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -10,8 +10,8 @@ use axum::body::Body; use axum::extract::connect_info::MockConnectInfo; use axum::http::{Method, Request, StatusCode}; use hosted_dns::{ - AppState, Ca, CaError, Change, ChangeId, Config, MIGRATOR, RecordSet, RecordType, Zone, - ZoneError, reap, router, + AppState, Ca, CaError, Change, ChangeId, Config, MIGRATOR, RecordSet, RecordType, TOKEN_LEN, + Zone, ZoneError, reap, router, }; use http_body_util::BodyExt; use serde_json::{Value, json}; @@ -33,6 +33,7 @@ type Sets = HashMap<(String, RecordType), RecordSet>; struct FakeZone { sets: Arc>, public: Arc>, + fail_next_apply: Arc>, } impl FakeZone { @@ -66,6 +67,9 @@ impl Zone for FakeZone { } async fn apply(&self, changes: Vec) -> Result { + if std::mem::take(&mut *self.fail_next_apply.lock().unwrap()) { + return Err(ZoneError("Route 53 is down".into())); + } let mut sets = self.sets.lock().unwrap(); let mut next = sets.clone(); for change in changes { @@ -270,7 +274,7 @@ async fn mint_grants_preferred_suffixed_or_random_labels() { let (name, token) = h.mint(Some("acme")).await; assert_eq!(name, format!("acme.{APEX}")); - assert_eq!(token.len(), 40); + assert_eq!(token.len(), TOKEN_LEN); // Taken and reserved labels get a 4-character suffix. let (taken, _) = h.mint(Some("acme")).await; @@ -697,3 +701,55 @@ async fn failed_issuance_still_removes_the_challenge() { None ); } + +#[tokio::test] +async fn reaper_never_frees_a_name_that_asked_for_a_certificate() { + let h = harness(100).await; + let (name, token) = h.mint(Some("certified")).await; + let body = json!({ "csr": csr(&[&name, &format!("*.{name}")]) }); + let (status, _) = h + .authed(Method::POST, &name, "/certificate", &token, Some(body)) + .await; + assert_eq!(status, StatusCode::OK); + + // No records were ever published, and the reservation is over 24 hours old. + sqlx::query("UPDATE domains SET reserved_at = now() - interval '25 hours'") + .execute(&h.db) + .await + .unwrap(); + reap(&h.state).await.unwrap(); + + let (status, _) = h.authed(Method::POST, &name, "/lease", &token, None).await; + assert_eq!(status, StatusCode::OK); + let (reissued, _) = h.mint(Some("certified")).await; + assert_ne!(reissued, name); +} + +#[tokio::test] +async fn reaper_keeps_an_expired_name_when_route53_fails_and_retries() { + let h = harness(100).await; + let (name, token) = h.mint(Some("stubborn")).await; + let records = json!({ "a": ["203.0.113.1"] }); + assert_eq!( + h.put_records(&name, &token, records).await, + StatusCode::NO_CONTENT + ); + sqlx::query("UPDATE domains SET lease_expires_at = now() - interval '1 minute'") + .execute(&h.db) + .await + .unwrap(); + + *h.zone.fail_next_apply.lock().unwrap() = true; + assert!(reap(&h.state).await.is_err()); + let retired: bool = sqlx::query_scalar("SELECT retired_at IS NOT NULL FROM domains") + .fetch_one(&h.db) + .await + .unwrap(); + assert!(!retired); + assert!(h.zone.values(&name, RecordType::A).is_some()); + + reap(&h.state).await.unwrap(); + let (status, _) = h.authed(Method::POST, &name, "/lease", &token, None).await; + assert_eq!(status, StatusCode::GONE); + assert_eq!(h.zone.values(&name, RecordType::A), None); +} From 2ff0d263f5380a3a2c91d5bb6a4d4800dd50011f Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 25 Sep 2026 05:03:36 +0000 Subject: [PATCH 6/7] Address review round 1 - ClusterDomain::parse accepts one DNS label under the configured apex; any other path name is 404 like an unknown name. - ClusterDomain::names() gives [name, *.name] to records, CSR check and order. - CertError maps into ApiError's own InvalidCsr, Ca and Zone variants, so IntoResponse has one arm per case. - Document CertError::Ca and CertError::Zone. - The mint test asserts the token contract (charset, distinct tokens) instead of its length; TOKEN_LEN is private again. --- src/api.rs | 57 ++++++++++++++++++++++++++++++++++----------------- src/cert.rs | 7 ++++--- src/domain.rs | 25 +++++++++++++++++++--- src/lib.rs | 1 - tests/api.rs | 33 +++++++++++++++++++++-------- 5 files changed, 88 insertions(+), 35 deletions(-) diff --git a/src/api.rs b/src/api.rs index df359ce..2661f32 100644 --- a/src/api.rs +++ b/src/api.rs @@ -47,6 +47,13 @@ pub struct AppState { mints: MintLimiter, } +impl AppState { + /// A path name that is not a label under the apex can not exist. + fn domain(&self, name: &str) -> Result { + ClusterDomain::parse(name, &self.config.apex).ok_or(ApiError::NotFound) + } +} + impl AppState { /// Wires the database, the DNS zone, the certificate authority and settings. pub fn new(db: PgPool, zone: Z, ca: C, config: Config) -> Self { @@ -143,9 +150,10 @@ struct RotateResponse { async fn rotate( State(state): State>>, - Path(name): Path, + Path(name): Path, headers: HeaderMap, ) -> Result, ApiError> { + let name = state.domain(&name)?; let mut tx = state.db.begin().await?; authorize_and_renew(&mut tx, &name, &headers).await?; let token = Token::generate(); @@ -166,9 +174,10 @@ struct LeaseResponse { async fn renew( State(state): State>>, - Path(name): Path, + Path(name): Path, headers: HeaderMap, ) -> Result, ApiError> { + let name = state.domain(&name)?; let mut tx = state.db.begin().await?; let lease_expires_at = authorize_and_renew(&mut tx, &name, &headers).await?; tx.commit().await?; @@ -190,10 +199,11 @@ struct ApexAddresses { async fn put_records( State(state): State>>, - Path(name): Path, + Path(name): Path, headers: HeaderMap, body: Result, JsonRejection>, ) -> Result { + let name = state.domain(&name)?; let Json(addresses) = body?; let mut tx = state.db.begin().await?; authorize_and_renew(&mut tx, &name, &headers).await?; @@ -243,9 +253,10 @@ async fn put_records( async fn release( State(state): State>>, - Path(name): Path, + Path(name): Path, headers: HeaderMap, ) -> Result { + let name = state.domain(&name)?; let mut tx = state.db.begin().await?; authorize_and_renew(&mut tx, &name, &headers).await?; retire(&state.zone, &mut tx, &name).await?; @@ -267,10 +278,11 @@ struct CertificateResponse { // challenge and one fails. Cloud runs one sync per Organization at a time. async fn certificate( State(state): State>>, - Path(name): Path, + Path(name): Path, headers: HeaderMap, body: Result, JsonRejection>, ) -> Result, ApiError> { + let name = state.domain(&name)?; let Json(request) = body?; let mut tx = state.db.begin().await?; authorize_and_renew(&mut tx, &name, &headers).await?; @@ -332,7 +344,7 @@ async fn retire( name: &ClusterDomain, ) -> Result<(), ApiError> { let mut changes = Vec::new(); - for set_name in [name.as_str().to_owned(), name.wildcard()] { + for set_name in name.names() { changes.extend( zone.record_sets(&set_name) .await? @@ -504,9 +516,12 @@ pub enum ApiError { /// Every label attempt collided: 503. #[error("no free label found, try again")] NamespaceExhausted, - /// Certificate request failed: 422, 429 or 502. - #[error(transparent)] - Cert(#[from] CertError), + /// CSR is not PEM PKCS#10 naming exactly `name` and `*.name`: 422. + #[error("{0}")] + InvalidCsr(String), + /// CA rate limit (429 with `Retry-After`) or failure (502). + #[error("certificate authority: {0}")] + Ca(CaError), /// Route 53 failed: 502. #[error("dns provider: {0}")] Zone(#[from] ZoneError), @@ -518,6 +533,16 @@ pub enum ApiError { Hash(#[from] bcrypt::BcryptError), } +impl From for ApiError { + fn from(error: CertError) -> Self { + match error { + CertError::InvalidCsr(why) => Self::InvalidCsr(why), + CertError::Ca(error) => Self::Ca(error), + CertError::Zone(error) => Self::Zone(error), + } + } +} + impl From for ApiError { fn from(rejection: JsonRejection) -> Self { Self::BadRequest(rejection.body_text()) @@ -557,21 +582,15 @@ impl IntoResponse for ApiError { None, true, ), - Self::Cert(CertError::InvalidCsr(_)) => { - (StatusCode::UNPROCESSABLE_ENTITY, "invalid_csr", None, true) - } - Self::Cert(CertError::Ca(CaError::RateLimited { until })) => ( + Self::InvalidCsr(_) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_csr", None, true), + Self::Ca(CaError::RateLimited { until }) => ( StatusCode::TOO_MANY_REQUESTS, "ca_rate_limited", Some(seconds_until(until)), true, ), - Self::Cert(CertError::Ca(CaError::Failed(_))) => { - (StatusCode::BAD_GATEWAY, "ca_error", None, true) - } - Self::Cert(CertError::Zone(_)) | Self::Zone(_) => { - (StatusCode::BAD_GATEWAY, "dns_provider_error", None, false) - } + Self::Ca(CaError::Failed(_)) => (StatusCode::BAD_GATEWAY, "ca_error", None, true), + Self::Zone(_) => (StatusCode::BAD_GATEWAY, "dns_provider_error", None, false), Self::Db(_) | Self::Hash(_) => { (StatusCode::INTERNAL_SERVER_ERROR, "internal", None, false) } diff --git a/src/cert.rs b/src/cert.rs index 27f6fe0..63472c6 100644 --- a/src/cert.rs +++ b/src/cert.rs @@ -61,8 +61,10 @@ pub enum CertError { /// The CSR is not PEM PKCS#10 naming exactly `name` and `*.name`. #[error("{0}")] InvalidCsr(String), + /// The CA refused, failed or rate-limited the order. #[error("certificate authority: {0}")] Ca(#[from] CaError), + /// Route 53 failed writing, syncing or removing the challenge. #[error("dns provider: {0}")] Zone(#[from] ZoneError), } @@ -128,7 +130,7 @@ fn csr_der(pem: &str, name: &ClusterDomain) -> Result, CertError> { let (_, csr) = X509CertificationRequest::from_der(&pem.contents) .map_err(|_| invalid("csr is not a valid PKCS#10 request"))?; - let expected = BTreeSet::from([name.as_str().to_owned(), name.wildcard()]); + let expected = BTreeSet::from(name.names()); let mut requested = BTreeSet::new(); for extension in csr.requested_extensions().into_iter().flatten() { let ParsedExtension::SubjectAlternativeName(san) = extension else { @@ -160,8 +162,7 @@ async fn order_with_dns01( name: &ClusterDomain, csr_der: &[u8], ) -> Result { - let names = [name.as_str().to_owned(), name.wildcard()]; - let (order, values) = ca.new_order(&names).await?; + let (order, values) = ca.new_order(&name.names()).await?; if values.is_empty() { return Ok(ca.finalize(order, csr_der).await?); } diff --git a/src/domain.rs b/src/domain.rs index 36da53b..43ef7e4 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -2,7 +2,7 @@ use bcrypt::BcryptError; use rand::Rng; -use serde::{Deserialize, Serialize}; +use serde::Serialize; /// Labels that are never granted verbatim; they get a suffix instead. const RESERVED: &[&str] = &[ @@ -30,7 +30,7 @@ const RESERVED: &[&str] = &[ const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; /// Length of a bearer token: 40 characters of `[a-z0-9]`, about 206 random bits. -pub const TOKEN_LEN: usize = 40; +const TOKEN_LEN: usize = 40; // Tokens are 200+ random bits, so bcrypt's work factor guards nothing brute force could reach. const BCRYPT_COST: u32 = 10; const SUFFIX_LEN: usize = 4; @@ -71,7 +71,7 @@ fn random_string(len: usize) -> String { } /// A granted Cluster Domain, e.g. `acme.ployz.app`: one label under the apex. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, sqlx::Type)] #[serde(transparent)] #[sqlx(transparent)] pub(crate) struct ClusterDomain(String); @@ -81,6 +81,20 @@ impl ClusterDomain { Self(format!("{label}.{apex}")) } + /// Accepts exactly one DNS label under `apex`. `None` for anything else, + /// which callers treat like an unknown name. + // The apex is runtime config, so this is a function rather than FromStr/serde. + pub(crate) fn parse(name: &str, apex: &str) -> Option { + let label = name.strip_suffix(apex)?.strip_suffix('.')?; + let valid = (1..=63).contains(&label.len()) + && label + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && !label.starts_with('-') + && !label.ends_with('-'); + valid.then(|| Self(name.to_owned())) + } + pub(crate) fn as_str(&self) -> &str { &self.0 } @@ -90,6 +104,11 @@ impl ClusterDomain { format!("*.{}", self.0) } + /// `[name, *.name]`: the records' owners and the certificate's names. + pub(crate) fn names(&self) -> [String; 2] { + [self.0.clone(), self.wildcard()] + } + /// `_acme-challenge.name`, where DNS-01 validates both `name` and `*.name`. pub(crate) fn challenge_name(&self) -> String { format!("_acme-challenge.{}", self.0) diff --git a/src/lib.rs b/src/lib.rs index 1805747..0300486 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,6 @@ mod zone; pub use acme::{Acme, AcmeConfig}; pub use api::{ApiError, AppState, Config, reap, router}; pub use cert::{Ca, CaError, CertError}; -pub use domain::TOKEN_LEN; pub use route53::Route53; pub use zone::{Change, ChangeId, RecordSet, RecordType, Zone, ZoneError}; diff --git a/tests/api.rs b/tests/api.rs index e7880cc..c9b741a 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -10,8 +10,8 @@ use axum::body::Body; use axum::extract::connect_info::MockConnectInfo; use axum::http::{Method, Request, StatusCode}; use hosted_dns::{ - AppState, Ca, CaError, Change, ChangeId, Config, MIGRATOR, RecordSet, RecordType, TOKEN_LEN, - Zone, ZoneError, reap, router, + AppState, Ca, CaError, Change, ChangeId, Config, MIGRATOR, RecordSet, RecordType, Zone, + ZoneError, reap, router, }; use http_body_util::BodyExt; use serde_json::{Value, json}; @@ -274,10 +274,16 @@ async fn mint_grants_preferred_suffixed_or_random_labels() { let (name, token) = h.mint(Some("acme")).await; assert_eq!(name, format!("acme.{APEX}")); - assert_eq!(token.len(), TOKEN_LEN); + assert!( + token + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()), + "{token}" + ); // Taken and reserved labels get a 4-character suffix. - let (taken, _) = h.mint(Some("acme")).await; + let (taken, other_token) = h.mint(Some("acme")).await; + assert_ne!(token, other_token); let suffix = label_of(&taken).strip_prefix("acme-").unwrap(); assert_eq!(suffix.len(), 4); let (reserved, _) = h.mint(Some("www")).await; @@ -375,11 +381,20 @@ async fn authentication_failures_are_distinguished() { assert_eq!(status, StatusCode::UNAUTHORIZED); let (status, _) = h.authed(Method::POST, &name, "/lease", "wrong", None).await; assert_eq!(status, StatusCode::UNAUTHORIZED); - let (status, body) = h - .authed(Method::POST, "nope.ployz.test", "/lease", "wrong", None) - .await; - assert_eq!(status, StatusCode::NOT_FOUND); - assert_eq!(body["error"], "not_found"); + // Unknown names and names that could never be granted look the same. + for unknown in [ + "nope.ployz.test", + "evil.example.com", + "a.b.ployz.test", + "-x.ployz.test", + "ployz.test", + ] { + let (status, body) = h + .authed(Method::POST, unknown, "/lease", "wrong", None) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{unknown}"); + assert_eq!(body["error"], "not_found"); + } } #[tokio::test] From 0719c70218f33b57c8c060c3cbb48ddfefdf25c6 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 25 Sep 2026 05:06:02 +0000 Subject: [PATCH 7/7] Share the label character check between minting and path parsing --- src/domain.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/domain.rs b/src/domain.rs index 43ef7e4..a07605a 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -52,12 +52,16 @@ pub(crate) fn candidates(preferred: Option<&str>) -> impl Iterator bool { - (3..=40).contains(&label.len()) - && label - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + (3..=40).contains(&label.len()) && is_ldh(label) +} + +/// Lowercase letters, digits and inner hyphens only. +fn is_ldh(label: &str) -> bool { + label + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') && !label.starts_with('-') && !label.ends_with('-') } @@ -86,12 +90,7 @@ impl ClusterDomain { // The apex is runtime config, so this is a function rather than FromStr/serde. pub(crate) fn parse(name: &str, apex: &str) -> Option { let label = name.strip_suffix(apex)?.strip_suffix('.')?; - let valid = (1..=63).contains(&label.len()) - && label - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') - && !label.starts_with('-') - && !label.ends_with('-'); + let valid = (1..=63).contains(&label.len()) && is_ldh(label); valid.then(|| Self(name.to_owned())) }