diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 779d777..968cf4b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -81,6 +81,10 @@ jobs: toolchain: ${{ matrix.rust-version }} components: clippy, rustfmt + - uses: perl-actions/install-with-cpm@8b1a9840b26cc3885ae2889749a48629be2501b0 # v1.9 + with: + install: IO::Socket::SSL + - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: | @@ -88,12 +92,19 @@ jobs: ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ + bin/pebble nginx/objs/**/CACHEDIR.TAG nginx/objs/**/ngx-debug nginx/objs/**/ngx-release + target/ key: ${{ runner.os }}-nginx-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-nginx- + - name: download pebble + run: | + build/get-pebble.sh + echo TEST_NGINX_PEBBLE_BINARY="$PWD/bin/pebble" >> "$GITHUB_ENV" + - name: build id: build run: make BUILD=${{ matrix.build }} -j $(nproc) build diff --git a/.github/workflows/sanitizers.yaml b/.github/workflows/sanitizers.yaml index e2ea8d5..e931bec 100644 --- a/.github/workflows/sanitizers.yaml +++ b/.github/workflows/sanitizers.yaml @@ -15,7 +15,8 @@ env: cargo rust-src rustfmt clang compiler-rt llvm git-core - make patch + make openssl patch which + perl-Digest-SHA perl-FindBin perl-IO-Socket-SSL perl-Test-Harness @@ -56,12 +57,18 @@ jobs: ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ + bin/pebble nginx/objs/**/CACHEDIR.TAG nginx/objs/**/ngx-debug nginx/objs/**/ngx-release key: ${{ runner.os }}-cargo-asan-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo-asan- + - name: download pebble + run: | + build/get-pebble.sh + echo TEST_NGINX_PEBBLE_BINARY="$PWD/bin/pebble" >> "$GITHUB_ENV" + - name: Configure and build nginx run: | make -j$(nproc) BUILD=sanitize build @@ -75,4 +82,4 @@ jobs: TEST_NGINX_GLOBALS: >- user root; run: | - make -j$(nproc) BUILD=sanitize test + make -j$(nproc) BUILD=sanitize TEST_PREREQ= test diff --git a/Cargo.lock b/Cargo.lock index d06bd8e..e7ffcd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.3" @@ -16,6 +31,9 @@ name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +dependencies = [ + "serde", +] [[package]] name = "annotate-snippets" @@ -27,12 +45,45 @@ dependencies = [ "yansi-term", ] +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bindgen" version = "0.69.5" @@ -124,6 +175,12 @@ dependencies = [ "libloading", ] +[[package]] +name = "constcat" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d3e02915a2cea4d74caa8681e2d44b1c3254bdbf17d11d41d587ff858832c" + [[package]] name = "dunce" version = "1.0.5" @@ -167,6 +224,45 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + [[package]] name = "glob" version = "0.3.2" @@ -193,6 +289,75 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-serde" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd" +dependencies = [ + "http", + "serde", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + [[package]] name = "itertools" version = "0.12.1" @@ -279,17 +444,49 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.59.0", +] + [[package]] name = "nginx-acme" version = "0.1.0" dependencies = [ + "anyhow", + "base64", + "bytes", + "constcat", "foreign-types", + "futures-channel", "http", + "http-body", + "http-body-util", + "http-serde", + "hyper", "libc", "nginx-sys", "ngx", "openssl", "openssl-sys", + "scopeguard", + "serde", + "serde_json", "siphasher", "thiserror", "zeroize", @@ -298,7 +495,7 @@ dependencies = [ [[package]] name = "nginx-sys" version = "0.5.0" -source = "git+https://github.com/nginx/ngx-rust?rev=95424ad7143de25124d9bedcbf9241fd72a24705#95424ad7143de25124d9bedcbf9241fd72a24705" +source = "git+https://github.com/nginx/ngx-rust?rev=ac60b788cc1b9e9d5e2e92058b54494dcecb1109#ac60b788cc1b9e9d5e2e92058b54494dcecb1109" dependencies = [ "bindgen 0.72.0", "cc", @@ -311,11 +508,13 @@ dependencies = [ [[package]] name = "ngx" version = "0.5.0" -source = "git+https://github.com/nginx/ngx-rust?rev=95424ad7143de25124d9bedcbf9241fd72a24705#95424ad7143de25124d9bedcbf9241fd72a24705" +source = "git+https://github.com/nginx/ngx-rust?rev=ac60b788cc1b9e9d5e2e92058b54494dcecb1109#ac60b788cc1b9e9d5e2e92058b54494dcecb1109" dependencies = [ "allocator-api2", + "async-task", "lock_api", "nginx-sys", + "pin-project-lite", ] [[package]] @@ -328,6 +527,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -373,6 +581,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.32" @@ -436,6 +656,12 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -461,12 +687,50 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.142" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "shlex" version = "1.3.0" @@ -479,6 +743,18 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "slab" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "syn" version = "2.0.104" @@ -510,6 +786,26 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "io-uring", + "libc", + "mio", + "pin-project-lite", + "slab", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -528,6 +824,21 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[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 = "which" version = "4.4.2" diff --git a/Cargo.toml b/Cargo.toml index 152a706..eee2d7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,24 +10,36 @@ rust-version = "1.81.0" crate-type = ["cdylib"] [dependencies] +anyhow = "1.0.98" +base64 = "0.22.1" +bytes = "1.10.1" +constcat = "0.6.1" +futures-channel = "0.3.31" http = "1.3.1" +http-body = "1.0.1" +http-body-util = "0.1.3" +http-serde = "2.1.1" +hyper = { version = "1.6.0", features = ["client", "http1"] } libc = "0.2.174" openssl = { version = "0.10.73", features = ["bindgen"] } openssl-foreign-types = { package = "foreign-types", version = "0.3" } openssl-sys = { version = "0.9.109", features = ["bindgen"] } +scopeguard = "1" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.142" siphasher = { version = "1.0.1", default-features = false } thiserror = { version = "2.0.12", default-features = false } zeroize = "1.8.1" [dependencies.nginx-sys] git = "https://github.com/nginx/ngx-rust" -rev = "95424ad7143de25124d9bedcbf9241fd72a24705" +rev = "ac60b788cc1b9e9d5e2e92058b54494dcecb1109" [dependencies.ngx] git = "https://github.com/nginx/ngx-rust" -rev = "95424ad7143de25124d9bedcbf9241fd72a24705" +rev = "ac60b788cc1b9e9d5e2e92058b54494dcecb1109" default-features = false -features = ["std"] +features = ["async", "serde", "std"] [features] default = ["export-modules"] diff --git a/build/get-pebble.sh b/build/get-pebble.sh new file mode 100755 index 0000000..aef2b3c --- /dev/null +++ b/build/get-pebble.sh @@ -0,0 +1,59 @@ +#!/bin/sh + +# Copyright (c) F5, Inc. +# +# This source code is licensed under the Apache License, Version 2.0 license +# found in the LICENSE file in the root directory of this source tree. + +set -e + +VERSION="${1:-2.8.0}" +SHA256SUM="$2" +TARGET=${3:-$PWD/bin/pebble} + +SHA256SUM_darwin_amd64=9b9625651f8ce47706235179503fec149f8f38bce2b2554efe8c0f2a021f877c +SHA256SUM_darwin_arm64=39e07d63dc776521f2ffe0584e5f4f081c984ac02742c882b430891d89f0c866 +SHA256SUM_linux_amd64=34595d915bbc2fc827affb3f58593034824df57e95353b031c8d5185724485ce +SHA256SUM_linux_arm64=0e70f2537353f61cbf06aa54740bf7f7bb5f963ba00e909f23af5f85bc13fd1a + +if "$TARGET" -version | grep "$VERSION"; then + exit 0 +fi + +SYSTEM=$(uname -s | tr "[:upper:]" "[:lower:]") +MACHINE=$(uname -m) +case "$MACHINE" in + aarch64) + MACHINE=arm64;; + x86_64) + MACHINE=amd64;; +esac + +if [ -z "$SHA256SUM" ]; then + eval "SHA256SUM=\$SHA256SUM_${SYSTEM}_${MACHINE}" +fi + +if echo "$SHA256SUM $TARGET" | shasum -a 256 -c; then + exit 0; +fi + +PREFIX="pebble-${SYSTEM}-${MACHINE}" + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +cd "$WORKDIR" + +curl -L -o "$PREFIX.tar.gz" \ + "https://github.com/letsencrypt/pebble/releases/download/v${VERSION}/${PREFIX}.tar.gz" + +if ! echo "$SHA256SUM $PREFIX.tar.gz" | shasum -a 256 -c; then + echo "checksum mismatch" + exit 1; +fi + +tar -xzf "$PREFIX.tar.gz" + +mkdir -p "$(dirname "$TARGET")" +mv "$PREFIX/$SYSTEM/$MACHINE/pebble" "$TARGET" +chmod +x "$TARGET" diff --git a/src/acme.rs b/src/acme.rs new file mode 100644 index 0000000..0a8104d --- /dev/null +++ b/src/acme.rs @@ -0,0 +1,542 @@ +// Copyright (c) F5, Inc. +// +// This source code is licensed under the Apache License, Version 2.0 license found in the +// LICENSE file in the root directory of this source tree. + +use core::cell::RefCell; +use core::ptr::NonNull; +use core::time::Duration; +use std::collections::VecDeque; +use std::string::{String, ToString}; + +use anyhow::{anyhow, Result}; +use bytes::Bytes; +use http::Uri; +use ngx::allocator::{Allocator, Box}; +use ngx::async_::sleep; +use ngx::collections::Vec; +use ngx::ngx_log_debug; +use openssl::pkey::{PKey, PKeyRef, Private}; +use openssl::x509::{self, extension as x509_ext, X509Req}; + +use self::account_key::AccountKey; +use self::types::{AuthorizationStatus, ChallengeKind, ChallengeStatus, OrderStatus}; +use crate::conf::identifier::Identifier; +use crate::conf::issuer::Issuer; +use crate::conf::order::CertificateOrder; +use crate::net::http::HttpClient; +use crate::time::Time; + +pub mod account_key; +pub mod solvers; +pub mod types; + +const DEFAULT_RETRY_INTERVAL: Duration = Duration::from_secs(1); +static REPLAY_NONCE: http::HeaderName = http::HeaderName::from_static("replay-nonce"); + +pub struct NewCertificateOutput { + pub chain: Bytes, + pub pkey: PKey, +} + +pub struct AuthorizationContext<'a> { + pub thumbprint: &'a [u8], +} + +pub struct AcmeClient<'a, Http> +where + Http: HttpClient, +{ + issuer: &'a Issuer, + http: Http, + log: NonNull, + key: AccountKey, + account: Option, + nonce: NoncePool, + directory: types::Directory, + solvers: Vec>, +} + +#[derive(Default)] +pub struct NoncePool(RefCell>); + +impl NoncePool { + pub fn get(&self) -> Option { + self.0.borrow_mut().pop_front() + } + + pub fn add(&self, nonce: String) { + self.0.borrow_mut().push_back(nonce); + } + + pub fn add_from_response(&self, res: &http::Response) { + if let Some(nonce) = try_get_header(res.headers(), &REPLAY_NONCE) { + self.add(nonce.to_string()); + } + } +} + +#[inline] +fn try_get_header( + headers: &http::HeaderMap, + key: K, +) -> Option<&str> { + headers.get(key).and_then(|x| x.to_str().ok()) +} + +impl<'a, Http> AcmeClient<'a, Http> +where + Http: HttpClient, +{ + pub fn new(http: Http, issuer: &'a Issuer, log: NonNull) -> Result { + let key = AccountKey::try_from( + issuer + .pkey + .as_ref() + .expect("checked during configuration load") + .as_ref(), + )?; + + Ok(Self { + issuer, + http, + log, + key, + account: None, + nonce: Default::default(), + directory: Default::default(), + solvers: Vec::new(), + }) + } + + pub fn add_solver(&mut self, s: impl solvers::ChallengeSolver + Send + 'a) { + self.solvers.push(ngx::allocator::unsize_box!(Box::new(s))) + } + + pub fn find_solver_for( + &self, + kind: &ChallengeKind, + ) -> Option<&Box> { + self.solvers.iter().find(|x| x.supports(kind)) + } + + pub fn is_supported_challenge(&self, kind: &ChallengeKind) -> bool { + self.solvers.iter().any(|s| s.supports(kind)) + } + + async fn get_directory(&mut self) -> Result { + let res = self.get(&self.issuer.uri).await?; + let directory = serde_json::from_slice(res.body())?; + + Ok(directory) + } + + async fn get_nonce(&self) -> Result { + let res = self.get(&self.directory.new_nonce).await?; + try_get_header(res.headers(), &REPLAY_NONCE) + .ok_or(anyhow!("no nonce in response headers")) + .map(String::from) + } + + pub async fn get(&self, url: &Uri) -> Result> { + let req = http::Request::builder() + .uri(url) + .method(http::Method::GET) + .header(http::header::CONTENT_LENGTH, 0) + .body(String::new())?; + Ok(self.http.request(req).await?) + } + + pub async fn post>( + &self, + url: &Uri, + payload: P, + ) -> Result> { + let mut fails = 0; + + let mut nonce = if let Some(nonce) = self.nonce.get() { + nonce + } else { + self.get_nonce().await? + }; + + ngx_log_debug!(self.log.as_ptr(), "sending request to {url:?}"); + let res = loop { + let body = crate::jws::sign_jws( + &self.key, + self.account.as_deref(), + &url.to_string(), + &nonce, + payload.as_ref(), + )?; + let req = http::Request::builder() + .uri(url) + .method(http::Method::POST) + .header(http::header::CONTENT_LENGTH, body.len()) + .header( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/jose+json"), + ) + .body(body)?; + + let res = match self.http.request(req).await { + Ok(res) => res, + Err(e) if fails >= 3 => return Err(e.into()), + // TODO: limit retries to connection errors + Err(_) => { + fails += 1; + sleep(DEFAULT_RETRY_INTERVAL).await; + ngx_log_debug!(self.log.as_ptr(), "retrying: {} of 3", fails + 1); + continue; + } + }; + + if res.status().is_success() { + break res; + } + + // 8555.6.5, when retrying in response to a "badNonce" error, the client MUST use + // the nonce provided in the error response. + nonce = try_get_header(res.headers(), &REPLAY_NONCE) + .ok_or(anyhow!("no nonce in response"))? + .to_string(); + + let err: types::Problem = serde_json::from_slice(res.body())?; + + let retriable = matches!( + err.kind, + types::ErrorKind::BadNonce | types::ErrorKind::RateLimited + ); + + if !retriable || fails >= 3 { + self.nonce.add(nonce); + return Err(err.into()); + } + + fails += 1; + + wait_for_retry(&res).await; + ngx_log_debug!(self.log.as_ptr(), "retrying: {} of 3", fails + 1); + }; + + self.nonce.add_from_response(&res); + + Ok(res) + } + + pub async fn new_account(&mut self) -> Result { + self.directory = self.get_directory().await?; + + // We validate that the strings are valid UTF-8 at configuration time. + let contact: Vec<&str> = self + .issuer + .contacts + .iter() + .map(|x| x.to_str()) + .collect::>()?; + + let payload = types::AccountRequest { + terms_of_service_agreed: self.issuer.accept_tos, + contact, + + ..Default::default() + }; + let payload = serde_json::to_string(&payload)?; + + let res = self.post(&self.directory.new_account, payload).await?; + + let key_id = res + .headers() + .get("location") + .ok_or(anyhow!("account URL unavailable"))? + .to_str()? + .to_string(); + self.account = Some(key_id); + self.nonce.add_from_response(&res); + + Ok(serde_json::from_slice(res.body())?) + } + + pub fn is_ready(&self) -> bool { + self.account.is_some() + } + + pub async fn new_certificate( + &mut self, + req: &CertificateOrder<&str, A>, + ) -> Result + where + A: Allocator, + { + ngx_log_debug!( + self.log.as_ptr(), + "new certificate request: {:?}", + req.identifiers + ); + let identifiers: Vec> = + req.identifiers.iter().map(|x| x.as_ref()).collect(); + + let payload = types::OrderRequest { + identifiers: &identifiers, + not_before: None, + not_after: None, + }; + + let payload = serde_json::to_string(&payload)?; + + let res = self.post(&self.directory.new_order, payload).await?; + + let order_url = res + .headers() + .get("location") + .and_then(|x| x.to_str().ok()) + .ok_or(anyhow!("no order URL"))?; + + let order_url = Uri::try_from(order_url)?; + let order: types::Order = serde_json::from_slice(res.body())?; + + let mut authorizations: Vec<(http::Uri, types::Authorization)> = Vec::new(); + for auth_url in order.authorizations { + let res = self.post(&auth_url, b"").await?; + let mut authorization: types::Authorization = serde_json::from_slice(res.body())?; + + authorization + .challenges + .retain(|x| self.is_supported_challenge(&x.kind)); + + if authorization.challenges.is_empty() { + anyhow::bail!("no supported challenge for {:?}", authorization.identifier) + } + + match authorization.status { + types::AuthorizationStatus::Pending => { + authorizations.push((auth_url, authorization)) + } + types::AuthorizationStatus::Valid => { + ngx_log_debug!( + self.log.as_ptr(), + "authorization {:?}: identifier {:?} already validated", + auth_url, + authorization.identifier + ); + } + status => anyhow::bail!( + "unexpected authorization status for {:?}: {:?}", + authorization.identifier, + status + ), + } + } + + let pkey = req.key.generate()?; + + let order = AuthorizationContext { + thumbprint: self.key.thumbprint(), + }; + + for (url, authorization) in authorizations { + self.do_authorization(&order, url, authorization).await?; + } + + let mut res = self.post(&order_url, b"").await?; + let mut order: types::Order = serde_json::from_slice(res.body())?; + + if order.status != OrderStatus::Ready { + anyhow::bail!("not ready"); + } + + let csr = make_certificate_request(&order.identifiers, &pkey).and_then(|x| x.to_der())?; + let payload = std::format!(r#"{{"csr":"{}"}}"#, crate::jws::base64url(csr)); + + match self.post(&order.finalize, payload).await { + Ok(x) => { + drop(order); + res = x; + order = serde_json::from_slice(res.body())?; + } + Err(err) => { + if !err.to_string().contains("orderNotReady") { + return Err(err); + } + order.status = OrderStatus::Processing + } + }; + + let mut tries = 10; + + while order.status == OrderStatus::Processing && tries > 0 { + tries -= 1; + wait_for_retry(&res).await; + + drop(order); + res = self.post(&order_url, b"").await?; + order = serde_json::from_slice(res.body())?; + } + + let certificate = order.certificate.ok_or(anyhow!("certificate not ready"))?; + + let chain = self.post(&certificate, b"").await?.into_body(); + + Ok(NewCertificateOutput { chain, pkey }) + } + + async fn do_authorization( + &self, + order: &AuthorizationContext<'_>, + url: http::Uri, + authorization: types::Authorization, + ) -> Result<()> { + let mut result = Err(anyhow!("no challenges")); + let identifier = authorization.identifier.as_ref(); + + for challenge in authorization.challenges { + result = self.do_challenge(order, &identifier, &challenge).await; + + if result.is_ok() { + break; + } + } + + result?; + + let mut tries = 10; + + let result = loop { + let res = self.post(&url, b"").await?; + let result: types::Authorization = serde_json::from_slice(res.body())?; + + if result.status != AuthorizationStatus::Pending || tries == 0 { + break result; + } + + tries -= 1; + wait_for_retry(&res).await; + }; + + ngx_log_debug!( + self.log.as_ptr(), + "authorization status for {:?}: {:?}", + authorization.identifier, + result.status + ); + + if result.status != AuthorizationStatus::Valid { + return Err(anyhow!("authorization failed")); + } + + Ok(()) + } + + async fn do_challenge( + &self, + ctx: &AuthorizationContext<'_>, + identifier: &Identifier<&str>, + challenge: &types::Challenge, + ) -> Result<()> { + let res = self.post(&challenge.url, b"").await?; + let result: types::Challenge = serde_json::from_slice(res.body())?; + + // Previous challenge result is still valid. + // Should not happen as we already skip valid authorizations. + if result.status == ChallengeStatus::Valid { + return Ok(()); + } + + let solver = self + .find_solver_for(&challenge.kind) + .ok_or(anyhow!("no solver for {:?}", challenge.kind))?; + + solver.register(ctx, identifier, challenge)?; + + scopeguard::defer! { + let _ = solver.unregister(identifier, challenge); + }; + + // "{}" in request payload initiates the challenge, "" checks the status. + let mut payload: &[u8] = b"{}"; + let mut tries = 10; + + let result = loop { + let res = self.post(&challenge.url, payload).await?; + let result: types::Challenge = serde_json::from_slice(res.body())?; + + if !matches!( + result.status, + ChallengeStatus::Pending | ChallengeStatus::Processing, + ) || tries == 0 + { + break result; + } + + tries -= 1; + payload = b""; + wait_for_retry(&res).await; + }; + + if result.status != ChallengeStatus::Valid { + return Err(result + .error + .map(Into::into) + .unwrap_or(anyhow!("unknown error"))); + } + + Ok(()) + } +} + +pub fn make_certificate_request( + identifiers: &[Identifier<&str>], + pkey: &PKeyRef, +) -> Result { + let mut req = X509Req::builder()?; + + let mut x509_name = x509::X509NameBuilder::new()?; + x509_name.append_entry_by_text("CN", identifiers[0].value())?; + let x509_name = x509_name.build(); + req.set_subject_name(&x509_name)?; + + let mut extensions = openssl::stack::Stack::new()?; + + let mut subject_alt_name = x509_ext::SubjectAlternativeName::new(); + for identifier in identifiers { + match identifier { + Identifier::Dns(name) => { + subject_alt_name.dns(name); + } + Identifier::Ip(addr) => { + subject_alt_name.ip(addr); + } + _ => (), + }; + } + let subject_alt_name = subject_alt_name.build(&req.x509v3_context(None))?; + extensions.push(subject_alt_name)?; + + req.add_extensions(&extensions)?; + + req.set_pubkey(pkey)?; + req.sign(pkey, openssl::hash::MessageDigest::sha256())?; + Ok(req.build()) +} + +/// Waits until the next retry attempt is allowed. +async fn wait_for_retry(res: &http::Response) { + let retry_after = res + .headers() + .get(http::header::RETRY_AFTER) + .and_then(parse_retry_after) + .unwrap_or(DEFAULT_RETRY_INTERVAL); + sleep(retry_after).await +} + +fn parse_retry_after(val: &http::HeaderValue) -> Option { + let val = val.to_str().ok()?; + + // Retry-After: + if let Ok(time) = Time::parse(val) { + return Some(time - Time::now()); + } + + // Retry-After: + val.parse().map(Duration::from_secs).ok() +} diff --git a/src/acme/account_key.rs b/src/acme/account_key.rs new file mode 100644 index 0000000..a60e8ac --- /dev/null +++ b/src/acme/account_key.rs @@ -0,0 +1,88 @@ +// Copyright (c) F5, Inc. +// +// This source code is licensed under the Apache License, Version 2.0 license found in the +// LICENSE file in the root directory of this source tree. + +use std::string::String; + +use ngx::collections::Vec; +use openssl::pkey::{Id, PKeyRef, Private}; +use serde::{Serialize, Serializer}; +use thiserror::Error; + +use crate::jws::{JsonWebKey, NewKeyError, ShaWithEcdsaKey, ShaWithRsaKey}; + +#[derive(Debug)] +pub struct AccountKey { + inner: AccountKeyInner, + thumbprint: String, +} + +#[derive(Debug, Error)] +pub enum AccountKeyError { + #[error(transparent)] + Jwk(#[from] crate::jws::NewKeyError), + #[error(transparent)] + Thumbprint(#[from] crate::jws::Error), +} + +#[derive(Debug)] +enum AccountKeyInner { + ShaWithEcdsa(ShaWithEcdsaKey), + ShaWithRsa(ShaWithRsaKey), +} + +impl AccountKey { + pub fn thumbprint(&self) -> &[u8] { + self.thumbprint.as_bytes() + } +} + +impl JsonWebKey for AccountKey { + fn alg(&self) -> &str { + match self.inner { + AccountKeyInner::ShaWithEcdsa(ref key) => key.alg(), + AccountKeyInner::ShaWithRsa(ref key) => key.alg(), + } + } + + fn compute_mac(&self, header: &[u8], payload: &[u8]) -> Result, crate::jws::Error> { + match self.inner { + AccountKeyInner::ShaWithEcdsa(ref key) => key.compute_mac(header, payload), + AccountKeyInner::ShaWithRsa(ref key) => key.compute_mac(header, payload), + } + } + + fn thumbprint(&self) -> Result { + Ok(self.thumbprint.clone()) + } +} + +impl Serialize for AccountKey { + #[inline] + fn serialize(&self, serializer: S) -> Result { + match self.inner { + AccountKeyInner::ShaWithEcdsa(ref key) => key.serialize(serializer), + AccountKeyInner::ShaWithRsa(ref key) => key.serialize(serializer), + } + } +} + +impl TryFrom<&PKeyRef> for AccountKey { + type Error = AccountKeyError; + + fn try_from(value: &PKeyRef) -> Result { + let inner = match value.id() { + Id::EC => value.try_into().map(AccountKeyInner::ShaWithEcdsa), + Id::RSA => value.try_into().map(AccountKeyInner::ShaWithRsa), + id => Err(NewKeyError::Algorithm(id)), + }?; + + let thumbprint = match inner { + AccountKeyInner::ShaWithEcdsa(ref key) => key.thumbprint(), + AccountKeyInner::ShaWithRsa(ref key) => key.thumbprint(), + }?; + + Ok(Self { inner, thumbprint }) + } +} diff --git a/src/acme/solvers.rs b/src/acme/solvers.rs new file mode 100644 index 0000000..23bc43d --- /dev/null +++ b/src/acme/solvers.rs @@ -0,0 +1,37 @@ +// Copyright (c) F5, Inc. +// +// This source code is licensed under the Apache License, Version 2.0 license found in the +// LICENSE file in the root directory of this source tree. + +use thiserror::Error; + +use super::types::{Challenge, ChallengeKind}; +use super::AuthorizationContext; +use crate::conf::identifier::Identifier; + +pub mod http; + +#[derive(Debug, Error)] +#[error("challenge registration failed: {0}")] +pub enum SolverError { + Alloc(#[from] ngx::allocator::AllocError), + Ssl(#[from] openssl::error::ErrorStack), + TryReserve(#[from] ngx::collections::TryReserveError), +} + +pub trait ChallengeSolver { + fn supports(&self, c: &ChallengeKind) -> bool; + + fn register( + &self, + ctx: &AuthorizationContext, + identifier: &Identifier<&str>, + challenge: &Challenge, + ) -> Result<(), SolverError>; + + fn unregister( + &self, + identifier: &Identifier<&str>, + challenge: &Challenge, + ) -> Result<(), SolverError>; +} diff --git a/src/acme/solvers/http.rs b/src/acme/solvers/http.rs new file mode 100644 index 0000000..5f7baa0 --- /dev/null +++ b/src/acme/solvers/http.rs @@ -0,0 +1,168 @@ +// Copyright (c) F5, Inc. +// +// This source code is licensed under the Apache License, Version 2.0 license found in the +// LICENSE file in the root directory of this source tree. + +use core::ptr; + +use nginx_sys::{ + ngx_array_push, ngx_buf_t, ngx_chain_t, ngx_conf_t, ngx_http_discard_request_body, + ngx_http_finalize_request, ngx_http_handler_pt, ngx_http_output_filter, + ngx_http_phases_NGX_HTTP_POST_READ_PHASE, ngx_http_request_t, +}; +use ngx::allocator::TryCloneIn; +use ngx::collections::RbTreeMap; +use ngx::core::{NgxStr, NgxString, SlabPool, Status}; +use ngx::http::HttpModuleMainConf; +use ngx::sync::RwLock; +use ngx::{http_request_handler, ngx_log_debug_http}; + +use super::{ChallengeSolver, SolverError}; +use crate::acme; +use crate::conf::identifier::Identifier; +use crate::conf::AcmeMainConfig; + +/// Registers http-01 challenge handler. +pub fn postconfiguration(cf: &mut ngx_conf_t, _amcf: &mut AcmeMainConfig) -> Result<(), Status> { + let cmcf = ngx::http::NgxHttpCoreModule::main_conf_mut(cf).expect("http core main conf"); + + // The handler needs to be set as early as possible, to ensure that it is not affected by the + // server configuration. + let h: *mut ngx_http_handler_pt = unsafe { + ngx_array_push(&mut cmcf.phases[ngx_http_phases_NGX_HTTP_POST_READ_PHASE as usize].handlers) + } + .cast(); + + if h.is_null() { + return Err(Status::NGX_ERROR); + } + + unsafe { *h = Some(handler) }; + + Ok(()) +} + +pub type Http01SolverState = RbTreeMap, NgxString, A>; + +#[derive(Debug)] +pub struct Http01Solver<'a>(&'a RwLock>); + +impl<'a> Http01Solver<'a> { + pub fn new(inner: &'a RwLock>) -> Self { + Self(inner) + } +} + +impl ChallengeSolver for Http01Solver<'_> { + fn supports(&self, c: &acme::types::ChallengeKind) -> bool { + matches!(c, crate::acme::types::ChallengeKind::Http01) + } + + fn register( + &self, + ctx: &acme::AuthorizationContext, + _identifier: &Identifier<&str>, + challenge: &acme::types::Challenge, + ) -> Result<(), SolverError> { + let alloc = self.0.read().allocator().clone(); + + let mut key_authorization = NgxString::new_in(alloc.clone()); + key_authorization.try_reserve_exact(challenge.token.len() + ctx.thumbprint.len() + 1)?; + // write to a preallocated buffer of a sufficient size should succeed + let _ = key_authorization.append_within_capacity(challenge.token.as_bytes()); + let _ = key_authorization.append_within_capacity(b"."); + let _ = key_authorization.append_within_capacity(ctx.thumbprint); + let token = NgxString::try_from_bytes_in(&challenge.token, alloc)?; + self.0.write().try_insert(token, key_authorization)?; + Ok(()) + } + + fn unregister( + &self, + _identifier: &Identifier<&str>, + challenge: &acme::types::Challenge, + ) -> Result<(), SolverError> { + self.0.write().remove(challenge.token.as_bytes()); + Ok(()) + } +} + +http_request_handler!(handler, |r: &mut ngx::http::Request| { + if r.method() != ngx::http::Method::GET { + return Status::NGX_DECLINED; + } + + let amcf = crate::HttpAcmeModule::main_conf(r).expect("acme config"); + let Some(amsh) = amcf.data else { + return Status::NGX_DECLINED; + }; + + let Some(token) = r + .path() + .as_bytes() + .strip_prefix(b"/.well-known/acme-challenge/") + else { + return Status::NGX_DECLINED; + }; + + let token = NgxStr::from_bytes(token); + + let key_auth = if let Some(resp) = amsh.http_01_state.read().get(token) { + resp.try_clone_in(r.pool()) + } else { + ngx_log_debug_http!(r, "acme/http-01: no challenge registered for {token}"); + return Status::NGX_DECLINED; + }; + + let Ok(key_auth) = key_auth else { + return Status::NGX_ERROR; + }; + + ngx_log_debug_http!(r, "acme/http-01: challenge for {token}"); + + let rc = Status(unsafe { ngx_http_discard_request_body(r.as_mut()) }); + if rc != Status::NGX_OK { + return rc; + } + + r.set_status(ngx::http::HTTPStatus::OK); + + r.set_content_length_n(key_auth.len()); + if r.add_header_out("connection", "close").is_none() + || r.add_header_out("content-type", "text/plain").is_none() + { + return Status::NGX_ERROR; + } + + let rc = r.send_header(); + if rc == Status::NGX_ERROR || rc > Status::NGX_OK { + return rc; + } + + let buf: *mut ngx_buf_t = r.pool().calloc_type(); + if buf.is_null() { + return Status::NGX_ERROR; + } + + let (p, len, _, _) = key_auth.into_raw_parts(); + + unsafe { + (*buf).set_memory(1); + (*buf).set_last_buf(if r.is_main() { 1 } else { 0 }); + (*buf).set_last_in_chain(1); + (*buf).start = p; + (*buf).end = p.add(len); + (*buf).pos = (*buf).start; + (*buf).last = (*buf).end; + } + + let mut chain = ngx_chain_t { + buf, + next: ptr::null_mut(), + }; + + let r: *mut ngx_http_request_t = r.into(); + unsafe { ngx_http_finalize_request(r, ngx_http_output_filter(r, &mut chain)) } + + Status::NGX_DONE +}); diff --git a/src/acme/types.rs b/src/acme/types.rs new file mode 100644 index 0000000..c0fd0f8 --- /dev/null +++ b/src/acme/types.rs @@ -0,0 +1,537 @@ +// Copyright (c) F5, Inc. +// +// This source code is licensed under the Apache License, Version 2.0 license found in the +// LICENSE file in the root directory of this source tree. + +#![allow(dead_code)] +use core::error::Error as StdError; +use core::fmt; +use std::string::{String, ToString}; + +use http::Uri; +use ngx::collections::Vec; +use serde::{Deserialize, Serialize}; + +use crate::conf::identifier::Identifier; + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DirectoryMetadata { + pub terms_of_service: Option, + #[serde(with = "http_serde::option::uri")] + pub website: Option, + pub caa_identities: Vec, + pub external_account_required: Option, +} + +/// RFC8555 Section 7.1.1 Directory +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Directory { + #[serde(with = "http_serde::uri")] + pub new_nonce: Uri, + #[serde(with = "http_serde::uri")] + pub new_account: Uri, + #[serde(with = "http_serde::uri")] + pub new_order: Uri, + #[serde(default, with = "http_serde::option::uri")] + pub new_authz: Option, + #[serde(with = "http_serde::uri")] + pub revoke_cert: Uri, + #[serde(with = "http_serde::uri")] + pub key_change: Uri, + #[serde(default)] + pub meta: DirectoryMetadata, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AccountStatus { + Valid, + Deactivated, + Revoked, +} + +/// RFC8555 Section 7.1.2 Account Object +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Account { + pub status: AccountStatus, + #[serde(default)] + pub contact: Vec, + #[serde(default)] + pub terms_of_service_agreed: bool, +} + +/// RFC8555 Section 7.3 Account Management +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountRequest<'a> { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub contact: Vec<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub terms_of_service_agreed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub only_return_existing: Option, + // external_account_binding: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum OrderStatus { + Pending, + Ready, + Processing, + Valid, + Invalid, +} + +/// RFC8555 Section 7.1.3 Order Object +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Order<'a> { + pub status: OrderStatus, + #[serde(default)] + pub expires: Option<&'a str>, + pub identifiers: Vec>, + #[serde(default)] + pub not_before: Option<&'a str>, + #[serde(default)] + pub not_after: Option<&'a str>, + #[serde(default)] + pub error: Option, + #[serde(deserialize_with = "deserialize_vec_of_uri")] + pub authorizations: Vec, + #[serde(with = "http_serde::uri")] + pub finalize: Uri, + #[serde(default, with = "http_serde::option::uri")] + pub certificate: Option, +} + +/// RFC8555 Section 7.4 Applying for Certificate Issuance +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OrderRequest<'a> { + pub identifiers: &'a [Identifier<&'a str>], + #[serde(default)] + pub not_before: Option, + #[serde(default)] + pub not_after: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum AuthorizationStatus { + Pending, + Valid, + Invalid, + Deactivated, + Expired, + Revoked, +} + +/// RFC8555 Section 7.1.4 Authorization Object +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Authorization { + pub identifier: Identifier, + pub status: AuthorizationStatus, + #[serde(default)] + pub expires: Option, + pub challenges: Vec, + #[serde(default)] + pub wildcard: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +pub enum ChallengeKind { + #[default] + #[serde(rename = "http-01")] + Http01, + #[serde(rename = "dns-01")] + Dns01, + #[serde(rename = "tls-alpn-01")] + TlsAlpn01, + #[serde(untagged)] + Other(String), +} + +impl From<&str> for ChallengeKind { + fn from(s: &str) -> Self { + match s { + "http-01" => ChallengeKind::Http01, + "dns-01" => ChallengeKind::Dns01, + "tls-alpn-01" => ChallengeKind::TlsAlpn01, + _ => ChallengeKind::Other(s.to_string()), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum ChallengeStatus { + Pending, + Processing, + Valid, + Invalid, +} + +/// RFC8555 Section 8 Challenge Object +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Challenge { + #[serde(rename = "type")] + pub kind: ChallengeKind, + #[serde(with = "http_serde::uri")] + pub url: Uri, + pub status: ChallengeStatus, + #[serde(default)] + pub validated: Option, + #[serde(default)] + pub error: Option, + #[serde(default)] // Some challenge types may not have a token. + pub token: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(from = "&str")] +pub enum ErrorKind { + AccountDoesNotExist, + AlreadyRevoked, + BadCsr, + BadNonce, + BadPublicKey, + BadRevocationReason, + BadSignatureAlgorithm, + Caa, + Compound, + Connection, + Dns, + ExternalAccountRequired, + IncorrectResponse, + InvalidContact, + Malformed, + OrderNotReady, + RateLimited, + RejectedIdentifier, + ServerInternal, + Tls, + Unauthorized, + UnsupportedContact, + UnsupportedIdentifier, + UserActionRequired, + Other(String), +} + +const ERROR_NAMESPACE: &str = "urn:ietf:params:acme:error:"; +const ERROR_KIND: &[(&str, ErrorKind)] = &[ + ("accountDoesNotExist", ErrorKind::AccountDoesNotExist), + ("alreadyRevoked", ErrorKind::AlreadyRevoked), + ("badCSR", ErrorKind::BadCsr), + ("badNonce", ErrorKind::BadNonce), + ("badPublicKey", ErrorKind::BadPublicKey), + ("badRevocationReason", ErrorKind::BadRevocationReason), + ("badSignatureAlgorithm", ErrorKind::BadSignatureAlgorithm), + ("caa", ErrorKind::Caa), + ("compound", ErrorKind::Compound), + ("connection", ErrorKind::Connection), + ("dns", ErrorKind::Dns), + ( + "externalAccountRequired", + ErrorKind::ExternalAccountRequired, + ), + ("incorrectResponse", ErrorKind::IncorrectResponse), + ("invalidContact", ErrorKind::InvalidContact), + ("malformed", ErrorKind::Malformed), + ("orderNotReady", ErrorKind::OrderNotReady), + ("rateLimited", ErrorKind::RateLimited), + ("rejectedIdentifier", ErrorKind::RejectedIdentifier), + ("serverInternal", ErrorKind::ServerInternal), + ("tls", ErrorKind::Tls), + ("unauthorized", ErrorKind::Unauthorized), + ("unsupportedContact", ErrorKind::UnsupportedContact), + ("unsupportedIdentifier", ErrorKind::UnsupportedIdentifier), + ("userActionRequired", ErrorKind::UserActionRequired), +]; + +impl fmt::Display for ErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (tag, kind) in ERROR_KIND { + if kind == self { + f.write_str(ERROR_NAMESPACE)?; + return f.write_str(tag); + } + } + if let ErrorKind::Other(str) = self { + return f.write_str(str); + } + unreachable!() + } +} + +impl From<&str> for ErrorKind { + fn from(value: &str) -> Self { + value + .strip_prefix(ERROR_NAMESPACE) + .and_then(|x| ERROR_KIND.iter().find(|(tag, _)| *tag == x)) + .map(|(_, kind)| kind.clone()) + .unwrap_or_else(|| ErrorKind::Other(value.to_string())) + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Subproblem { + #[serde(rename = "type")] + pub kind: ErrorKind, + pub detail: String, + #[serde(default)] + pub identifier: Option>, +} + +/// RFC7807 Problem Document +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Problem { + #[serde(rename = "type")] + pub kind: ErrorKind, + #[serde(default)] + pub detail: String, + #[serde(default)] + pub instance: Option, + #[serde(default)] + pub subproblems: Vec, +} + +impl fmt::Display for Problem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.kind.fmt(f)?; + f.write_str(": ")?; + f.write_str(&self.detail)?; + + if self.kind == ErrorKind::UserActionRequired { + if let Some(instance) = self.instance.as_ref() { + write!(f, "({instance})")?; + } + } + + Ok(()) + } +} + +impl StdError for Problem {} + +#[derive(Debug, Eq, PartialEq)] +pub enum ProblemCategory { + /// The account did not pass server validation. + Account, + /// The request message was malformed. + /// This may point to a problem with the order or account. + Malformed, + /// The order did not pass server validation. + Order, + Other, +} + +impl Problem { + pub fn category(&self) -> ProblemCategory { + match self.kind { + ErrorKind::AccountDoesNotExist + | ErrorKind::BadPublicKey + | ErrorKind::BadSignatureAlgorithm + | ErrorKind::ExternalAccountRequired + | ErrorKind::InvalidContact + | ErrorKind::UnsupportedContact + | ErrorKind::UserActionRequired => ProblemCategory::Account, + + ErrorKind::BadCsr + | ErrorKind::Caa + | ErrorKind::RejectedIdentifier + | ErrorKind::UnsupportedIdentifier => ProblemCategory::Order, + + ErrorKind::Malformed => ProblemCategory::Malformed, + + _ => ProblemCategory::Other, + } + } +} + +fn deserialize_vec_of_uri<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct UriSeqVisitor; + + impl<'de> serde::de::Visitor<'de> for UriSeqVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an array of URLs") + } + + fn visit_seq(self, mut seq: S) -> Result + where + S: serde::de::SeqAccess<'de>, + { + use serde::de; + + let mut val = Vec::new(); + + while let Some(value) = seq.next_element::<&str>()? { + let uri = value + .parse() + .map_err(|_| de::Error::invalid_value(de::Unexpected::Str(value), &self))?; + val.push(uri) + } + + Ok(val) + } + } + + deserializer.deserialize_seq(UriSeqVisitor) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn order() { + let _order: Order = serde_json::from_str( + r#"{ + "status": "valid", + "expires": "2016-01-20T14:09:07.99Z", + + "identifiers": [ + { "type": "dns", "value": "www.example.org" }, + { "type": "dns", "value": "example.org" } + ], + + "notBefore": "2016-01-01T00:00:00Z", + "notAfter": "2016-01-08T00:00:00Z", + + "authorizations": [ + "https://example.com/acme/authz/PAniVnsZcis", + "https://example.com/acme/authz/r4HqLzrSrpI" + ], + + "finalize": "https://example.com/acme/order/TOlocE8rfgo/finalize", + + "certificate": "https://example.com/acme/cert/mAt3xBGaobw" + }"#, + ) + .unwrap(); + } + + #[test] + fn authorization() { + let auth: Authorization = serde_json::from_str( + r#" + { + "status": "pending", + "identifier": { + "type": "dns", + "value": "www.example.org" + }, + "challenges": [ + { + "type": "http-01", + "url": "https://example.com/acme/chall/prV_B7yEyA4", + "status": "pending", + "token": "LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0" + }, + { + "type": "dns-01", + "url": "https://example.com/acme/chall/Rg5dV14Gh1Q", + "status": "pending", + "token": "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA" + }, + { + "type": "tls-alpn-01", + "url": "https://example.com/acme/chall/TJds3qqnMAf", + "status": "pending", + "token": "nRsTyUVQh1YAwovxwzAEVgtFLRV47f7HAcRfl6pED5Y=" + } + ], + + "wildcard": false + }"#, + ) + .unwrap(); + + assert_eq!(auth.challenges.len(), 3); + assert_eq!(auth.challenges[0].kind, ChallengeKind::Http01); + assert_eq!(auth.challenges[1].kind, ChallengeKind::Dns01); + assert_eq!(auth.challenges[2].kind, ChallengeKind::TlsAlpn01); + } + + #[test] + fn error_kind() { + let err: Problem = serde_json::from_str( + r#" + { + "type": "urn:ietf:params:acme:error:badNonce", + "detail": "The client sent an unacceptable anti-replay nonce" + }"#, + ) + .unwrap(); + + assert_eq!(err.kind, ErrorKind::BadNonce); + + let err: Problem = serde_json::from_str( + r#" + { + "type": "urn:ietf:params:acme:error:caa", + "detail": "CAA records forbid the CA from issuing a certificate" + }"#, + ) + .unwrap(); + + assert_eq!(err.kind, ErrorKind::Caa); + + let err: Problem = serde_json::from_str( + r#" + { + "type": "unknown-error", + "detail": "Some unknown error" + }"#, + ) + .unwrap(); + + assert_eq!(err.kind, ErrorKind::Other("unknown-error".to_string())); + + let err: Problem = serde_json::from_str( + r#" + { + "type": "urn:ietf:params:acme:error:malformed", + "detail": "Some of the identifiers requested were rejected", + "subproblems": [ + { + "type": "urn:ietf:params:acme:error:malformed", + "detail": "Invalid underscore in DNS name \"_example.org\"", + "identifier": { + "type": "dns", + "value": "_example.org" + } + }, + { + "type": "urn:ietf:params:acme:error:rejectedIdentifier", + "detail": "This CA will not issue for \"example.net\"", + "identifier": { + "type": "dns", + "value": "example.net" + } + } + ] + }"#, + ) + .unwrap(); + + assert_eq!(err.kind, ErrorKind::Malformed); + assert_eq!(err.subproblems.len(), 2); + assert_eq!( + err.subproblems[0].identifier, + Some(Identifier::Dns("_example.org".to_string())) + ) + } +} diff --git a/src/conf/identifier.rs b/src/conf/identifier.rs index 5fc88dc..fbc18f6 100644 --- a/src/conf/identifier.rs +++ b/src/conf/identifier.rs @@ -1,17 +1,58 @@ +use core::str; + use ngx::allocator::{AllocError, Allocator, TryCloneIn}; use ngx::core::NgxString; +use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Eq, Hash, PartialOrd, Ord)] +#[derive(Clone, Debug, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(tag = "type", content = "value", rename_all = "camelCase")] pub enum Identifier { Dns(S), Ip(S), + #[serde(untagged)] + Other { + #[serde(rename = "type")] + kind: S, + value: S, + }, } impl Identifier { + pub fn as_ref(&self) -> Identifier<&T> + where + S: AsRef, + T: ?Sized, + { + match self { + Identifier::Dns(x) => Identifier::Dns(x.as_ref()), + Identifier::Ip(x) => Identifier::Ip(x.as_ref()), + Identifier::Other { kind, value } => Identifier::Other { + kind: kind.as_ref(), + value: value.as_ref(), + }, + } + } + + /// Borrows the current identifier with a str reference as an underlying data. + pub fn as_str(&self) -> Result, str::Utf8Error> + where + S: AsRef<[u8]>, + { + match self { + Identifier::Dns(value) => str::from_utf8(value.as_ref()).map(Identifier::Dns), + Identifier::Ip(value) => str::from_utf8(value.as_ref()).map(Identifier::Ip), + Identifier::Other { kind, value } => Ok(Identifier::Other { + kind: str::from_utf8(kind.as_ref())?, + value: str::from_utf8(value.as_ref())?, + }), + } + } + pub fn value(&self) -> &S { match self { Identifier::Dns(value) => value, Identifier::Ip(value) => value, + Identifier::Other { value, .. } => value, } } } @@ -25,6 +66,16 @@ where match (self, other) { (Identifier::Dns(x), Identifier::Dns(y)) => x == y, (Identifier::Ip(x), Identifier::Ip(y)) => x == y, + ( + Identifier::Other { + kind: xk, + value: xv, + }, + Identifier::Other { + kind: yk, + value: yv, + }, + ) => xk == yk && xv == yv, _ => false, } } @@ -43,6 +94,36 @@ where match self { Identifier::Dns(x) => try_clone(x).map(Identifier::Dns), Identifier::Ip(x) => try_clone(x).map(Identifier::Ip), + Identifier::Other { kind, value } => Ok(Identifier::Other { + kind: try_clone(kind)?, + value: try_clone(value)?, + }), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_identifier() { + let id: Identifier<&str> = + serde_json::from_str(r#"{ "type": "dns", "value": "example.com" }"#).unwrap(); + assert_eq!(id, Identifier::Dns("example.com")); + + let id: Identifier<&str> = + serde_json::from_str(r#"{ "type": "ip", "value": "127.0.0.1" }"#).unwrap(); + assert_eq!(id, Identifier::Ip("127.0.0.1")); + + let id: Identifier<&str> = + serde_json::from_str(r#"{ "type": "email", "value": "admin@example.test" }"#).unwrap(); + assert_eq!( + id, + Identifier::Other { + kind: "email", + value: "admin@example.test" + } + ); + } +} diff --git a/src/conf/issuer.rs b/src/conf/issuer.rs index 0e6731f..f017d5a 100644 --- a/src/conf/issuer.rs +++ b/src/conf/issuer.rs @@ -1,6 +1,6 @@ +use core::error::Error as StdError; use core::ptr::{self, NonNull}; use core::str; - use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::DirBuilderExt; @@ -24,7 +24,7 @@ use super::pkey::PrivateKey; use super::ssl::NgxSsl; use super::AcmeMainConfig; use crate::state::certificate::{CertificateContext, CertificateContextInner}; -use crate::state::issuer::IssuerContext; +use crate::state::issuer::{IssuerContext, IssuerState}; use crate::time::{Time, TimeRange}; const ACCOUNT_KEY_FILE: &str = "account.key"; @@ -96,6 +96,19 @@ impl Issuer { }) } + /// Checks if the issuer can be used. + pub fn is_valid(&self) -> bool { + self.data + .is_some_and(|x| x.read().state != IssuerState::Invalid) + } + + /// Marks the issuer as misconfigured or otherwise unusable. + pub fn set_invalid(&self, err: &dyn StdError) { + if let Some(data) = self.data.as_ref() { + data.write().set_invalid(err); + } + } + /// Finalizes configuration after parsing the issuer block. pub fn init(&mut self, cf: &mut ngx_conf_t) -> Result<(), IssuerError> { if self.uri.host().is_none() { @@ -210,6 +223,17 @@ impl Issuer { Ok(()) } + pub fn write_state_file(&self, path: impl AsRef<[u8]>, buf: &[u8]) -> Result<(), Status> { + let state_dir = unsafe { StateDir::from_ptr(self.state_path) }; + + if let Some(state_dir) = state_dir { + let path = state_dir.full_path(path); + + state_dir.write(&path, buf).map_err(|_| Status::NGX_ERROR)?; + } + Ok(()) + } + /// Recovers an existing account key from the sources listed below or creates a new one: /// - full path specified with `account_key=file` /// - previous configuration cycle diff --git a/src/conf/order.rs b/src/conf/order.rs index b450e61..b5775d6 100644 --- a/src/conf/order.rs +++ b/src/conf/order.rs @@ -53,6 +53,20 @@ where std::format!("{name}-{hash:x}", hash = hasher.finish()) } + + pub fn to_str_order(&self, alloc: NewA) -> CertificateOrder<&str, NewA> + where + NewA: Allocator + Clone, + S: AsRef<[u8]>, + { + let mut identifiers = Vec::, NewA>::new_in(alloc); + identifiers.extend(self.identifiers.iter().map(|x| x.as_str().unwrap())); + + CertificateOrder { + identifiers, + key: self.key.clone(), + } + } } impl Hash for CertificateOrder diff --git a/src/jws.rs b/src/jws.rs new file mode 100644 index 0000000..4bb9fa0 --- /dev/null +++ b/src/jws.rs @@ -0,0 +1,282 @@ +// Copyright (c) F5, Inc. +// +// This source code is licensed under the Apache License, Version 2.0 license found in the +// LICENSE file in the root directory of this source tree. + +use std::borrow::ToOwned; +use std::string::String; + +use ngx::collections::{vec, Vec}; +use openssl::bn::{BigNum, BigNumContext, BigNumRef}; +use openssl::error::ErrorStack; +use openssl::nid::Nid; +use openssl::pkey::{Id, PKey, PKeyRef, Private}; +use openssl_foreign_types::ForeignTypeRef; +use serde::{ser::SerializeMap, Serialize, Serializer}; +use thiserror::Error; + +/// A JWS header, as defined in RFC 8555 Section 6.2. +#[derive(Serialize)] +struct JwsHeader<'a, Jwk: JsonWebKey> { + pub alg: &'a str, + pub nonce: &'a str, + pub url: &'a str, + // Per 8555 6.2, "jwk" and "kid" fields are mutually exclusive. + #[serde(flatten)] + pub key: JwsHeaderKey<'a, Jwk>, +} + +#[derive(Serialize)] +#[serde(untagged)] +enum JwsHeaderKey<'a, Jwk: JsonWebKey> { + Jwk { jwk: &'a Jwk }, + Kid { kid: &'a str }, +} + +#[derive(Debug, Error)] +pub enum Error { + #[error("serialize failed: {0}")] + Serialize(#[from] serde_json::Error), + #[error("crypto: {0}")] + Crypto(#[from] openssl::error::ErrorStack), +} + +#[derive(Debug, Error)] +pub enum NewKeyError { + #[error("unsupported key algorithm ({0:?})")] + Algorithm(Id), + #[error("unsupported key size ({0})")] + Size(u32), +} + +pub trait JsonWebKey: Serialize { + fn alg(&self) -> &str; + fn compute_mac(&self, header: &[u8], payload: &[u8]) -> Result, Error>; + fn thumbprint(&self) -> Result; +} + +#[derive(Debug)] +pub(crate) struct ShaWithEcdsaKey(PKey); + +#[derive(Debug)] +pub(crate) struct ShaWithRsaKey(PKey); + +#[inline] +pub fn base64url>(buf: T) -> String { + base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, buf) +} + +pub fn sign_jws( + jwk: &Jwk, + kid: Option<&str>, + url: &str, + nonce: &str, + payload: &[u8], +) -> Result { + let key = match kid { + Some(kid) => JwsHeaderKey::Kid { kid }, + None => JwsHeaderKey::Jwk { jwk }, + }; + + let header = JwsHeader { + alg: jwk.alg(), + nonce, + url, + key, + }; + + let header_json = serde_json::to_vec(&header)?; + let header = base64url(&header_json); + let payload = base64url(payload); + let signature = jwk.compute_mac(header.as_bytes(), payload.as_bytes())?; + let signature = base64url(signature); + + Ok(std::format!( + r#"{{"protected":"{header}","payload":"{payload}","signature":"{signature}"}}"# + )) +} + +impl JsonWebKey for ShaWithEcdsaKey { + fn alg(&self) -> &str { + match self.0.bits() { + 256 => "ES256", + 384 => "ES384", + 521 => "ES512", + _ => unreachable!("unsupported key size"), + } + } + + fn compute_mac(&self, header: &[u8], payload: &[u8]) -> Result, Error> { + let bits = self.0.bits() as usize; + let pad_to = bits.div_ceil(8); + + let md = match bits { + 384 => openssl::hash::MessageDigest::sha384(), + 521 => openssl::hash::MessageDigest::sha512(), + _ => openssl::hash::MessageDigest::sha256(), + }; + + let mut signer = openssl::sign::Signer::new(md, &self.0)?; + signer.update(header)?; + signer.update(b".")?; + signer.update(payload)?; + + let mut buf = vec![0u8; signer.len()?]; + + let len = signer.sign(&mut buf)?; + buf.truncate(len); + + let sig = openssl::ecdsa::EcdsaSig::from_der(&buf)?; + buf.resize(2 * pad_to, 0); + + bn2binpad(sig.r(), &mut buf[0..pad_to])?; + bn2binpad(sig.s(), &mut buf[pad_to..])?; + + Ok(buf) + } + + fn thumbprint(&self) -> Result { + let data = serde_json::to_vec(self)?; + Ok(base64url(openssl::sha::sha256(&data))) + } +} + +impl Serialize for ShaWithEcdsaKey { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::Error; + + let ec_key = self.0.ec_key().map_err(Error::custom)?; + let group = ec_key.group(); + + let (crv, bits): (_, usize) = match group.curve_name() { + Some(Nid::X9_62_PRIME256V1) => ("P-256", 256), + Some(Nid::SECP384R1) => ("P-384", 384), + Some(Nid::SECP521R1) => ("P-521", 521), + _ => return Err(Error::custom("unsupported curve")), + }; + + let mut x = BigNum::new().map_err(Error::custom)?; + let mut y = BigNum::new().map_err(Error::custom)?; + let mut ctx = BigNumContext::new().map_err(Error::custom)?; + ec_key + .public_key() + .affine_coordinates(group, &mut x, &mut y, &mut ctx) + .map_err(Error::custom)?; + + let mut buf = vec![0u8; bits.div_ceil(8)]; + + let x = base64url(bn2binpad(&x, &mut buf).map_err(Error::custom)?); + let y = base64url(bn2binpad(&y, &mut buf).map_err(Error::custom)?); + + let mut map = serializer.serialize_map(Some(4))?; + // order is important for thumbprint generation (RFC7638) + map.serialize_entry("crv", crv)?; + map.serialize_entry("kty", "EC")?; + map.serialize_entry("x", &x)?; + map.serialize_entry("y", &y)?; + map.end() + } +} + +impl TryFrom<&PKeyRef> for ShaWithEcdsaKey { + type Error = NewKeyError; + + fn try_from(pkey: &PKeyRef) -> Result { + if pkey.id() != Id::EC { + return Err(NewKeyError::Algorithm(pkey.id())); + } + + let bits = pkey.bits(); + if !matches!(bits, 256 | 384 | 521) { + return Err(NewKeyError::Size(bits)); + } + + Ok(Self(pkey.to_owned())) + } +} + +impl JsonWebKey for ShaWithRsaKey { + fn alg(&self) -> &str { + "RS256" + } + + fn compute_mac(&self, header: &[u8], payload: &[u8]) -> Result, Error> { + let md = openssl::hash::MessageDigest::sha256(); + + let mut signer = openssl::sign::Signer::new(md, &self.0)?; + signer.update(header)?; + signer.update(b".")?; + signer.update(payload)?; + + let mut buf = vec![0u8; signer.len()?]; + + let len = signer.sign(&mut buf)?; + buf.truncate(len); + + Ok(buf) + } + + fn thumbprint(&self) -> Result { + let data = serde_json::to_vec(self)?; + Ok(base64url(openssl::sha::sha256(&data))) + } +} + +impl Serialize for ShaWithRsaKey { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::Error; + + let rsa = self.0.rsa().map_err(Error::custom)?; + + let num_bytes = rsa.e().num_bytes().max(rsa.n().num_bytes()) as usize; + let mut buf = vec![0u8; num_bytes]; + + let e = base64url(bn2bin(rsa.e(), &mut buf).map_err(Error::custom)?); + let n = base64url(bn2bin(rsa.n(), &mut buf).map_err(Error::custom)?); + + let mut map = serializer.serialize_map(Some(3))?; + // order is important for thumbprint generation (RFC7638) + map.serialize_entry("e", &e)?; + map.serialize_entry("kty", "RSA")?; + map.serialize_entry("n", &n)?; + map.end() + } +} + +impl TryFrom<&PKeyRef> for ShaWithRsaKey { + type Error = NewKeyError; + + fn try_from(pkey: &PKeyRef) -> Result { + if pkey.id() != Id::RSA { + return Err(NewKeyError::Algorithm(pkey.id())); + } + + let bits = pkey.bits(); + if bits < 2048 { + return Err(NewKeyError::Size(bits)); + } + + Ok(Self(pkey.to_owned())) + } +} + +/// [openssl] offers [BigNumRef::to_vec()], but we want to avoid an extra allocation. +fn bn2bin<'a>(bn: &BigNumRef, out: &'a mut [u8]) -> Result<&'a [u8], ErrorStack> { + debug_assert!(bn.num_bytes() as usize <= out.len()); + let n = unsafe { openssl_sys::BN_bn2bin(bn.as_ptr(), out.as_mut_ptr()) }; + if n >= 0 { + Ok(&out[..n as usize]) + } else { + Err(ErrorStack::get()) + } +} + +/// [openssl] offers [BigNumRef::to_vec_padded()], but we want to avoid an extra allocation. +fn bn2binpad<'a>(bn: &BigNumRef, out: &'a mut [u8]) -> Result<&'a [u8], ErrorStack> { + let n = unsafe { openssl_sys::BN_bn2binpad(bn.as_ptr(), out.as_mut_ptr(), out.len() as _) }; + if n >= 0 { + Ok(&out[..n as usize]) + } else { + Err(ErrorStack::get()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 3b6d33d..d7355af 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,21 +1,36 @@ #![no_std] extern crate std; -use core::ptr; +use core::time::Duration; +use core::{cmp, ptr}; use nginx_sys::{ - ngx_conf_t, ngx_http_add_variable, ngx_http_module_t, ngx_int_t, ngx_module_t, ngx_uint_t, - NGX_HTTP_MODULE, + ngx_conf_t, ngx_cycle_t, ngx_http_add_variable, ngx_http_module_t, ngx_int_t, ngx_module_t, + ngx_uint_t, NGX_HTTP_MODULE, NGX_LOG_ERR, NGX_LOG_INFO, NGX_LOG_NOTICE, NGX_LOG_WARN, }; +use ngx::allocator::AllocError; use ngx::core::Status; use ngx::http::{HttpModule, HttpModuleMainConf, HttpModuleServerConf}; +use ngx::log::ngx_cycle_log; +use ngx::{ngx_log_debug, ngx_log_error}; +use openssl::x509::X509; +use time::TimeRange; +use zeroize::Zeroizing; +use crate::acme::AcmeClient; use crate::conf::{AcmeMainConfig, AcmeServerConfig, NGX_HTTP_ACME_COMMANDS}; +use crate::net::http::NgxHttpClient; +use crate::time::Time; +use crate::util::{ngx_process, NgxProcess}; use crate::variables::NGX_HTTP_ACME_VARS; +mod acme; mod conf; +mod jws; +mod net; mod state; mod time; +mod util; mod variables; #[derive(Debug)] @@ -47,7 +62,7 @@ pub static mut ngx_http_acme_module: ngx_module_t = ngx_module_t { init_master: None, init_module: None, - init_process: None, + init_process: Some(ngx_http_acme_init_worker), init_thread: None, exit_thread: None, exit_process: None, @@ -89,6 +104,231 @@ impl HttpModule for HttpAcmeModule { return e.into(); } + /* http-01 challenge handler */ + + if let Err(err) = acme::solvers::http::postconfiguration(cf, amcf) { + return err.into(); + }; + Status::NGX_OK.into() } } + +extern "C" fn ngx_http_acme_init_worker(cycle: *mut ngx_cycle_t) -> ngx_int_t { + if !matches!(ngx_process(), NgxProcess::Single | NgxProcess::Worker(0)) { + return Status::NGX_OK.into(); + } + + // SAFETY: cycle passed to the module callbacks is never NULL + let cycle = unsafe { &mut *cycle }; + + let amcf = HttpAcmeModule::main_conf(cycle).expect("acme main conf"); + + if !amcf.is_configured() { + ngx_log_debug!(cycle.log, "acme: not configured"); + return Status::NGX_OK.into(); + } + + if amcf.issuers.iter().all(|x| x.orders.is_empty()) { + ngx_log_error!(NGX_LOG_NOTICE, cycle.log, "acme: no certificates"); + return Status::NGX_OK.into(); + } + + ngx::async_::spawn(ngx_http_acme_main_loop(amcf)).detach(); + + Status::NGX_OK.into() +} + +// TODO: configure intervals per issuer. +const ACME_MIN_INTERVAL: Duration = Duration::from_secs(30); +const ACME_MAX_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); +const ACME_DEFAULT_INTERVAL: Duration = ACME_MIN_INTERVAL.saturating_mul(10); + +async fn ngx_http_acme_main_loop(amcf: &AcmeMainConfig) { + loop { + if unsafe { ngx::ffi::ngx_terminate } != 0 || unsafe { ngx::ffi::ngx_exiting } != 0 { + return; + } + + let next = ngx_http_acme_update_certificates(amcf).await; + let next = (next - Time::now()).max(ACME_MIN_INTERVAL); + + ngx_log_debug!(ngx_cycle_log().as_ptr(), "acme: next update in {next:?}"); + ngx::async_::sleep(next).await; + } +} + +async fn ngx_http_acme_update_certificates(amcf: &AcmeMainConfig) -> Time { + let log = ngx_cycle_log(); + let now = Time::now(); + let mut next = now + ACME_MAX_INTERVAL; + + ngx_log_debug!(log.as_ptr(), "acme: updating certificates"); + + for issuer in &amcf.issuers[..] { + if !issuer.is_valid() { + continue; + } + + let issuer_next = match ngx_http_acme_update_certificates_for_issuer(amcf, issuer).await { + Ok(x) => x, + Err(err) => { + // Check if the server rejected this ACME account configuration. + if err + .downcast_ref::() + .is_some_and(|err| { + matches!(err.category(), acme::types::ProblemCategory::Account) + }) + { + ngx_log_error!( + NGX_LOG_ERR, + log.as_ptr(), + "acme issuer \"{}\" is not valid: {}", + issuer.name, + err + ); + + issuer.set_invalid(err.as_ref()); + continue; + } + + ngx_log_error!( + NGX_LOG_INFO, + log.as_ptr(), + "update failed for acme issuer \"{}\": {}", + issuer.name, + err + ); + now + ACME_DEFAULT_INTERVAL + } + }; + next = cmp::min(next, issuer_next); + } + + next +} + +async fn ngx_http_acme_update_certificates_for_issuer( + amcf: &AcmeMainConfig, + issuer: &conf::issuer::Issuer, +) -> anyhow::Result AcmeSharedData @@ -32,8 +34,10 @@ where A: Allocator + Clone, { pub fn try_new_in(alloc: A) -> Result { + let http_01_state = acme::solvers::http::Http01SolverState::try_new_in(alloc.clone())?; Ok(Self { issuers: Queue::try_new_in(alloc)?, + http_01_state: RwLock::new(http_01_state), }) } } diff --git a/src/state/certificate.rs b/src/state/certificate.rs index 1740167..94fd90a 100644 --- a/src/state/certificate.rs +++ b/src/state/certificate.rs @@ -1,6 +1,10 @@ +use core::error::Error as StdError; +use core::time::Duration; +use std::string::ToString; + use ngx::allocator::{AllocError, Allocator, TryCloneIn}; use ngx::collections::Vec; -use ngx::core::{Pool, SlabPool}; +use ngx::core::{NgxString, Pool, SlabPool}; use ngx::sync::RwLock; use zeroize::Zeroize; @@ -29,10 +33,24 @@ impl CertificateContext { } #[derive(Debug, Default, PartialEq, Eq)] -pub enum CertificateState { +pub enum CertificateState +where + A: Allocator + Clone, +{ #[default] Pending, + InitialRequestFailed { + fails: usize, + reason: NgxString, + }, Ready, + RenewalFailed { + fails: usize, + reason: NgxString, + }, + Invalid { + reason: NgxString, + }, } #[derive(Debug)] @@ -40,7 +58,7 @@ pub struct CertificateContextInner where A: Allocator + Clone, { - pub state: CertificateState, + pub state: CertificateState, pub chain: Vec, pub pkey: Vec, pub valid: TimeRange, @@ -54,6 +72,12 @@ where type Target = CertificateContextInner; fn try_clone_in(&self, alloc: A) -> Result, AllocError> { + let state = if self.is_ready() { + CertificateState::Ready + } else { + CertificateState::Pending + }; + let mut chain = Vec::new_in(alloc.clone()); chain .try_reserve_exact(self.chain.len()) @@ -66,7 +90,7 @@ where pkey.extend(self.pkey.iter()); Ok(Self::Target { - state: CertificateState::Ready, + state, chain, pkey, valid: self.valid.clone(), @@ -139,8 +163,59 @@ where Ok(self.next) } + pub fn set_error(&mut self, err: &dyn StdError) -> Time { + let mut reason = NgxString::new_in(self.chain.allocator().clone()); + + let fails = match self.state { + CertificateState::InitialRequestFailed { fails, .. } => fails + 1, + CertificateState::RenewalFailed { fails, .. } => fails + 1, + CertificateState::Invalid { .. } => return Time::MAX, + _ => 1, + }; + + let msg = err.to_string(); + // it is fine to have an empty reason if we failed to reserve space for the message + if reason.try_reserve_exact(msg.len()).is_ok() { + let _ = reason.append_within_capacity(msg.as_bytes()); + } + + self.state = match self.state { + CertificateState::Pending | CertificateState::InitialRequestFailed { .. } => { + CertificateState::InitialRequestFailed { fails, reason } + } + + CertificateState::Ready | CertificateState::RenewalFailed { .. } => { + CertificateState::RenewalFailed { fails, reason } + } + + _ => unreachable!(), + }; + + let interval = Duration::from_secs(match fails { + 1 => 60, + 2 => 600, + 3 => 6000, + _ => 24 * 60 * 60, + }); + + self.next = Time::now() + jitter(interval, 2); + self.next + } + + pub fn set_invalid(&mut self, err: &dyn StdError) { + let mut reason = NgxString::new_in(self.chain.allocator().clone()); + + let msg = err.to_string(); + // it is fine to have an empty reason if we failed to reserve space for the message + if reason.try_reserve_exact(msg.len()).is_ok() { + let _ = reason.append_within_capacity(msg.as_bytes()); + } + + self.state = CertificateState::Invalid { reason }; + } + pub fn chain(&self) -> Option<&[u8]> { - if matches!(self.state, CertificateState::Ready) { + if self.is_ready() { return Some(&self.chain); } @@ -148,12 +223,23 @@ where } pub fn pkey(&self) -> Option<&[u8]> { - if matches!(self.state, CertificateState::Ready) { + if self.is_ready() { return Some(&self.pkey); } None } + + pub fn is_ready(&self) -> bool { + matches!( + self.state, + CertificateState::Ready | CertificateState::RenewalFailed { .. } + ) + } + + pub fn is_renewable(&self) -> bool { + !matches!(self.state, CertificateState::Invalid { .. }) && Time::now() >= self.next + } } impl Drop for CertificateContextInner diff --git a/src/state/issuer.rs b/src/state/issuer.rs index fe370e0..f81cd9f 100644 --- a/src/state/issuer.rs +++ b/src/state/issuer.rs @@ -1,3 +1,4 @@ +use core::error::Error as StdError; use core::ptr; use ngx::allocator::{AllocError, TryCloneIn}; @@ -5,12 +6,18 @@ use ngx::collections::Queue; use ngx::core::SlabPool; use ngx::sync::RwLock; +use super::certificate::{CertificateContext, CertificateContextInner, SharedCertificateContext}; use crate::conf::issuer::Issuer; -use super::certificate::{CertificateContext, CertificateContextInner, SharedCertificateContext}; +#[derive(Debug, Eq, PartialEq)] +pub enum IssuerState { + Idle, + Invalid, +} #[derive(Debug)] pub struct IssuerContext { + pub state: IssuerState, // Using Queue here to ensure address stability. #[allow(unused)] pub certificates: Queue, @@ -31,6 +38,13 @@ impl IssuerContext { *value = CertificateContext::Shared(unsafe { &*ptr::from_ref(ctx) }); } - Ok(IssuerContext { certificates }) + Ok(IssuerContext { + state: IssuerState::Idle, + certificates, + }) + } + + pub fn set_invalid(&mut self, _err: &dyn StdError) { + self.state = IssuerState::Invalid; } } diff --git a/src/time.rs b/src/time.rs index c33d238..ec47944 100644 --- a/src/time.rs +++ b/src/time.rs @@ -1,12 +1,14 @@ use core::ops; use core::time::Duration; -use nginx_sys::{ngx_random, ngx_time, time_t}; +use nginx_sys::{ngx_parse_http_time, ngx_random, ngx_time, time_t, NGX_ERROR}; use openssl::asn1::Asn1TimeRef; use openssl::x509::X509Ref; use openssl_foreign_types::ForeignTypeRef; use thiserror::Error; +pub const NGX_INVALID_TIME: time_t = NGX_ERROR as _; + #[derive(Debug, Error)] #[error("invalid time")] pub struct InvalidTime; @@ -46,8 +48,6 @@ impl TryFrom<&Asn1TimeRef> for Time { #[cfg(not(any(openssl = "openssl111", openssl = "awslc", openssl = "boringssl")))] fn try_from(asn1time: &Asn1TimeRef) -> Result { - pub const NGX_INVALID_TIME: time_t = nginx_sys::NGX_ERROR as _; - use openssl_sys::{ ASN1_TIME_print, BIO_free, BIO_get_mem_data, BIO_new, BIO_s_mem, BIO_write, }; @@ -80,12 +80,24 @@ impl TryFrom<&Asn1TimeRef> for Time { } impl Time { + pub const MAX: Self = Self(time_t::MAX); // time_t can be signed, but is not supposed to be negative pub const MIN: Self = Self(0); pub fn now() -> Self { Self(ngx_time()) } + + pub fn parse(value: &str) -> Result { + let p = value.as_ptr().cast_mut(); + + let tm = unsafe { ngx_parse_http_time(p, value.len()) }; + if tm == NGX_INVALID_TIME { + return Err(InvalidTime); + } + + Ok(Self(tm)) + } } /// This type represents an open-ended interval of time measured in seconds. diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..7248331 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,88 @@ +// Copyright (c) F5, Inc. +// +// This source code is licensed under the Apache License, Version 2.0 license found in the +// LICENSE file in the root directory of this source tree. + +use core::ops::{Deref, DerefMut}; +use core::ptr::NonNull; + +use nginx_sys::{ngx_log_t, ngx_uint_t}; +use ngx::allocator::AllocError; +use ngx::core::Pool; +use ngx::ffi::ngx_pool_t; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum NgxProcess { + Single, + Master, + Signaller, + Worker(ngx_uint_t), + Helper, +} + +pub fn ngx_process() -> NgxProcess { + let process = unsafe { nginx_sys::ngx_process } as u32; + match process { + nginx_sys::NGX_PROCESS_SINGLE => NgxProcess::Single, + nginx_sys::NGX_PROCESS_MASTER => NgxProcess::Master, + nginx_sys::NGX_PROCESS_SIGNALLER => NgxProcess::Signaller, + nginx_sys::NGX_PROCESS_WORKER => NgxProcess::Worker(unsafe { nginx_sys::ngx_worker }), + #[cfg(not(windows))] + nginx_sys::NGX_PROCESS_HELPER => NgxProcess::Helper, + _ => unreachable!("unknown process type {}", process), + } +} +pub struct OwnedPool(Pool); +impl OwnedPool { + pub fn new(size: usize, log: NonNull) -> Result { + let pool = unsafe { nginx_sys::ngx_create_pool(size, log.as_ptr()) }; + if pool.is_null() { + return Err(AllocError); + } + Ok(Self(unsafe { Pool::from_ngx_pool(pool) })) + } +} + +impl AsRef for OwnedPool { + fn as_ref(&self) -> &ngx_pool_t { + self.0.as_ref() + } +} + +impl AsMut for OwnedPool { + fn as_mut(&mut self) -> &mut ngx_pool_t { + self.0.as_mut() + } +} + +impl AsRef for OwnedPool { + fn as_ref(&self) -> &Pool { + &self.0 + } +} + +impl AsMut for OwnedPool { + fn as_mut(&mut self) -> &mut Pool { + &mut self.0 + } +} + +impl Deref for OwnedPool { + type Target = Pool; + + fn deref(&self) -> &Self::Target { + self.as_ref() + } +} + +impl DerefMut for OwnedPool { + fn deref_mut(&mut self) -> &mut Self::Target { + self.as_mut() + } +} + +impl Drop for OwnedPool { + fn drop(&mut self) { + unsafe { nginx_sys::ngx_destroy_pool(self.0.as_mut()) }; + } +} diff --git a/t/acme_http.t b/t/acme_http.t new file mode 100644 index 0000000..d5759e2 --- /dev/null +++ b/t/acme_http.t @@ -0,0 +1,142 @@ +#!/usr/bin/perl + +# Copyright (c) F5, Inc. +# +# This source code is licensed under the Apache License, Version 2.0 license +# found in the LICENSE file in the root directory of this source tree. + +# Tests for ACME client: HTTP-01 challenge. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use IO::Select; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; +use Test::Nginx::ACME; +use Test::Nginx::DNS; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http http_ssl socket_ssl/) + ->has_daemon('openssl'); + +$t->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + resolver 127.0.0.1:%%PORT_8980_UDP%%; + + acme_issuer default { + uri https://acme.test:%%PORT_9000%%/dir; + ssl_trusted_certificate acme.test.crt; + state_path %%TESTDIR%%; + accept_terms_of_service; + } + + server { + listen 127.0.0.1:8080; + server_name example.test; + } + + server { + listen 127.0.0.1:8443 ssl; + server_name example.test; + + acme_certificate default; + + ssl_certificate $acme_certificate; + ssl_certificate_key $acme_certificate_key; + } +} + +EOF + +$t->write_file('openssl.conf', <testdir(); + +foreach my $name ('acme.test') { + system('openssl req -x509 -new ' + . "-config $d/openssl.conf -subj /CN=$name/ " + . "-out $d/$name.crt -keyout $d/$name.key " + . ">>$d/openssl.out 2>&1") == 0 + or die "Can't create certificate for $name: $!\n"; +} + +my $dp = port(8980, udp=>1); +my @dc = ( + { name => 'acme.test', A => '127.0.0.1' }, + { name => 'example.test', A => '127.0.0.1' } +); + +my $acme = Test::Nginx::ACME->new($t, port(9000), port(9001), + $t->testdir . '/acme.test.crt', + $t->testdir . '/acme.test.key', + http_port => port(8080), + tls_port => port(8443), + dns_port => $dp, + nosleep => 1, + validity => 60, +); + +$t->run_daemon(\&Test::Nginx::DNS::dns_test_daemon, $t, $dp, \@dc); +$t->waitforfile($t->testdir . '/' . $dp); + +$t->run_daemon(\&Test::Nginx::ACME::acme_test_daemon, $t, $acme); +$t->waitforsocket('127.0.0.1:' . $acme->port()); +$t->write_file('acme-root.crt', $acme->trusted_ca()); + +$t->write_file('index.html', 'SUCCESS'); +$t->plan(1)->run(); + +############################################################################### + +$acme->wait_certificate('example.test') or die "no certificate"; + +like(get(8443, 'example.test', 'acme-root'), qr/SUCCESS/, 'tls request'); + +############################################################################### + +sub get { + my ($port, $host, $ca) = @_; + + $ca = undef if $IO::Socket::SSL::VERSION < 2.062 + || !eval { Net::SSLeay::X509_V_FLAG_PARTIAL_CHAIN() }; + + http_get('/', + PeerAddr => '127.0.0.1:' . port($port), + SSL => 1, + $ca ? ( + SSL_ca_file => "$d/$ca.crt", + SSL_verifycn_name => $host, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_PEER(), + ) : () + ); +} + +############################################################################### diff --git a/t/acme_key_type.t b/t/acme_key_type.t new file mode 100644 index 0000000..9bc4d24 --- /dev/null +++ b/t/acme_key_type.t @@ -0,0 +1,178 @@ +#!/usr/bin/perl + +# Copyright (c) F5, Inc. +# +# This source code is licensed under the Apache License, Version 2.0 license +# found in the LICENSE file in the root directory of this source tree. + +# Tests for ACME client: key algorithm configuration. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use IO::Select; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; +use Test::Nginx::ACME; +use Test::Nginx::DNS; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http http_ssl sni socket_ssl_sni/) + ->has_daemon('openssl'); + +$t->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + resolver 127.0.0.1:%%PORT_8980_UDP%%; + + acme_issuer default { + uri https://acme.test:%%PORT_9000%%/dir; + ssl_trusted_certificate acme.test.crt; + state_path %%TESTDIR%%; + accept_terms_of_service; + } + + server { + listen 127.0.0.1:8080; + server_name .example.test; + } + + server { + listen 127.0.0.1:8443 ssl; + server_name ecdsa.example.test; + + acme_certificate default + ecdsa.example.test + example.test + key=ecdsa; + + ssl_certificate $acme_certificate; + ssl_certificate_key $acme_certificate_key; + } + + server { + listen 127.0.0.1:8443 ssl; + server_name rsa.example.test; + + acme_certificate default + rsa.example.test + example.test + key=rsa; + + ssl_certificate $acme_certificate; + ssl_certificate_key $acme_certificate_key; + } +} + +EOF + +$t->write_file('openssl.conf', <testdir(); + +foreach my $name ('acme.test') { + system('openssl req -x509 -new ' + . "-config $d/openssl.conf -subj /CN=$name/ " + . "-out $d/$name.crt -keyout $d/$name.key " + . ">>$d/openssl.out 2>&1") == 0 + or die "Can't create certificate for $name: $!\n"; +} + +my $dp = port(8980, udp=>1); +my @dc = ( + { name => 'acme.test', A => '127.0.0.1' }, + { match => qr/^(\w+\.)?example.test$/, A => '127.0.0.1' } +); + +my $acme = Test::Nginx::ACME->new($t, port(9000), port(9001), + $t->testdir . '/acme.test.crt', + $t->testdir . '/acme.test.key', + http_port => port(8080), + tls_port => port(8443), + dns_port => $dp, + nosleep => 1, + validity => 60, +); + +$t->run_daemon(\&Test::Nginx::DNS::dns_test_daemon, $t, $dp, \@dc); +$t->waitforfile($t->testdir . '/' . $dp); + +$t->run_daemon(\&Test::Nginx::ACME::acme_test_daemon, $t, $acme); +$t->waitforsocket('127.0.0.1:' . $acme->port()); +$t->write_file('acme-root.crt', $acme->trusted_ca()); + +$t->write_file('index.html', 'SUCCESS'); +$t->plan(2)->run(); + +############################################################################### + +$acme->wait_certificate('ecdsa.example.test') or die "no certificate"; +$acme->wait_certificate('rsa.example.test') or die "no certificate"; + +like(get(8443, 'rsa.example.test', 'acme-root', 'RSA'), qr/SUCCESS/ms, + 'ACME cert RSA'); +like(get(8443, 'ecdsa.example.test', 'acme-root', 'ECDSA'), qr/SUCCESS/ms, + 'ACME cert ECDSA'); + +############################################################################### + +sub get { + my ($port, $host, $ca, $type) = @_; + + my $ctx_cb = sub { + my $ctx = shift; + return unless defined $type; + my $ssleay = Net::SSLeay::SSLeay(); + return if ($ssleay < 0x1000200f || $ssleay == 0x20000000); + my @sigalgs = ('RSA+SHA256:PSS+SHA256', 'RSA+SHA256'); + @sigalgs = ($type . '+SHA256') unless $type eq 'RSA'; + # SSL_CTRL_SET_SIGALGS_LIST + Net::SSLeay::CTX_ctrl($ctx, 98, 0, $sigalgs[0]) + or Net::SSLeay::CTX_ctrl($ctx, 98, 0, $sigalgs[1]) + or die("Failed to set sigalgs"); + }; + + $ca = undef if $IO::Socket::SSL::VERSION < 2.062 + || !eval { Net::SSLeay::X509_V_FLAG_PARTIAL_CHAIN() }; + + return http_get('/', + PeerAddr => '127.0.0.1:' . port($port), + SSL => 1, + SSL_cipher_list => $type, + SSL_create_ctx_callback => $ctx_cb, + SSL_hostname => $host, + $ca ? ( + SSL_ca_file => "$d/$ca.crt", + SSL_verifycn_name => $host, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_PEER(), + ) : (), + ); +} + +############################################################################### diff --git a/t/acme_multiple_issuers.t b/t/acme_multiple_issuers.t new file mode 100644 index 0000000..485d687 --- /dev/null +++ b/t/acme_multiple_issuers.t @@ -0,0 +1,178 @@ +#!/usr/bin/perl + +# Copyright (c) F5, Inc. +# +# This source code is licensed under the Apache License, Version 2.0 license +# found in the LICENSE file in the root directory of this source tree. + +# Tests for ACME client: configuration with multiple ACME issuers. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use IO::Select; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; +use Test::Nginx::ACME; +use Test::Nginx::DNS; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http http_ssl sni socket_ssl_sni/) + ->has_daemon('openssl'); + +$t->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + resolver 127.0.0.1:%%PORT_8980_UDP%%; + + acme_issuer first { + uri https://acme.test:%%PORT_9000%%/dir; + ssl_trusted_certificate acme.test.crt; + state_path %%TESTDIR%%/first; + accept_terms_of_service; + } + + acme_issuer second { + uri https://acme.test:%%PORT_9002%%/dir; + ssl_trusted_certificate acme.test.crt; + state_path %%TESTDIR%%/second; + accept_terms_of_service; + } + + server { + listen 127.0.0.1:8080; + server_name .first.test .second.test; + } + + server { + listen 127.0.0.1:8443 ssl; + server_name .first.test; + + acme_certificate first; + + ssl_certificate $acme_certificate; + ssl_certificate_key $acme_certificate_key; + } + + server { + listen 127.0.0.1:8443 ssl; + server_name .second.test; + + acme_certificate second; + + ssl_certificate $acme_certificate; + ssl_certificate_key $acme_certificate_key; + } +} + +EOF + +$t->write_file('openssl.conf', <testdir(); + +foreach my $name ('acme.test') { + system('openssl req -x509 -new ' + . "-config $d/openssl.conf -subj /CN=$name/ " + . "-out $d/$name.crt -keyout $d/$name.key " + . ">>$d/openssl.out 2>&1") == 0 + or die "Can't create certificate for $name: $!\n"; +} + +my $dp = port(8980, udp=>1); +my @dc = ( + { name => 'acme.test', A => '127.0.0.1' }, + { match => qr/^(www\.)?first.test$/, A => '127.0.0.1' }, + { match => qr/^(www\.)?second.test$/, A => '127.0.0.1' }, +); + +my $acme1 = Test::Nginx::ACME->new($t, port(9000), port(9001), + $t->testdir . '/acme.test.crt', + $t->testdir . '/acme.test.key', + http_port => port(8080), + tls_port => port(8443), + dns_port => $dp, + nosleep => 1, + state => $t->testdir . '/first', +); + +my $acme2 = Test::Nginx::ACME->new($t, port(9002), port(9003), + $t->testdir . '/acme.test.crt', + $t->testdir . '/acme.test.key', + http_port => port(8080), + tls_port => port(8443), + dns_port => $dp, + nosleep => 1, + state => $t->testdir . '/second', +); + + +$t->run_daemon(\&Test::Nginx::DNS::dns_test_daemon, $t, $dp, \@dc); +$t->waitforfile($t->testdir . '/' . $dp); + +$t->run_daemon(\&Test::Nginx::ACME::acme_test_daemon, $t, $acme1); +$t->waitforsocket('127.0.0.1:' . $acme1->port()); +$t->write_file('acme-root-1.crt', $acme1->trusted_ca()); + +$t->run_daemon(\&Test::Nginx::ACME::acme_test_daemon, $t, $acme2); +$t->waitforsocket('127.0.0.1:' . $acme2->port()); +$t->write_file('acme-root-2.crt', $acme2->trusted_ca()); + +$t->write_file('index.html', 'SUCCESS'); +$t->plan(2)->run(); + +############################################################################### + +$acme1->wait_certificate('first.test') or die "no certificate"; +$acme2->wait_certificate('second.test') or die "no certificate"; + +like(get(8443, 'first.test', 'acme-root-1'), qr/SUCCESS/, 'tls request - 1'); +like(get(8443, 'second.test', 'acme-root-2'), qr/SUCCESS/, 'tls request - 2'); + +############################################################################### + +sub get { + my ($port, $host, $ca) = @_; + + $ca = undef if $IO::Socket::SSL::VERSION < 2.062 + || !eval { Net::SSLeay::X509_V_FLAG_PARTIAL_CHAIN() }; + + http_get('/', + PeerAddr => '127.0.0.1:' . port($port), + SSL => 1, + SSL_hostname => $host, + $ca ? ( + SSL_ca_file => "$d/$ca.crt", + SSL_verifycn_name => $host, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_PEER(), + ) : () + ); +} + +############################################################################### diff --git a/t/acme_reload.t b/t/acme_reload.t new file mode 100644 index 0000000..953e119 --- /dev/null +++ b/t/acme_reload.t @@ -0,0 +1,156 @@ +#!/usr/bin/perl + +# Copyright (c) F5, Inc. +# +# This source code is licensed under the Apache License, Version 2.0 license +# found in the LICENSE file in the root directory of this source tree. + +# Tests for ACME client: state recovery after reload. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use IO::Select; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; +use Test::Nginx::ACME; +use Test::Nginx::DNS; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http http_ssl socket_ssl/) + ->has_daemon('openssl'); + +$t->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + resolver 127.0.0.1:%%PORT_8980_UDP%%; + + acme_issuer default { + uri https://acme.test:%%PORT_9000%%/dir; + ssl_trusted_certificate acme.test.crt; + state_path %%TESTDIR%%; + accept_terms_of_service; + } + + server { + listen 127.0.0.1:8080; + server_name example.test; + } + + server { + listen 127.0.0.1:8443 ssl; + server_name example.test; + + acme_certificate default; + + ssl_certificate $acme_certificate; + ssl_certificate_key $acme_certificate_key; + } +} + +EOF + +$t->write_file('openssl.conf', <testdir(); + +foreach my $name ('acme.test') { + system('openssl req -x509 -new ' + . "-config $d/openssl.conf -subj /CN=$name/ " + . "-out $d/$name.crt -keyout $d/$name.key " + . ">>$d/openssl.out 2>&1") == 0 + or die "Can't create certificate for $name: $!\n"; +} + +my $dp = port(8980, udp=>1); +my @dc = ( + { name => 'acme.test', A => '127.0.0.1' }, + { name => 'example.test', A => '127.0.0.1' } +); + +my $acme = Test::Nginx::ACME->new($t, port(9000), port(9001), + $t->testdir . '/acme.test.crt', + $t->testdir . '/acme.test.key', + http_port => port(8080), + tls_port => port(8443), + dns_port => $dp, + nosleep => 1, +); + +$t->run_daemon(\&Test::Nginx::DNS::dns_test_daemon, $t, $dp, \@dc); +$t->waitforfile($t->testdir . '/' . $dp); + +$t->run_daemon(\&Test::Nginx::ACME::acme_test_daemon, $t, $acme); +$t->waitforsocket('127.0.0.1:' . $acme->port()); +$t->write_file('acme-root.crt', $acme->trusted_ca()); + +$t->write_file('index.html', 'SUCCESS'); +$t->plan(3)->run(); + +############################################################################### + +$acme->wait_certificate('example.test') or die "no certificate"; + +like(get(8443, 'example.test', 'acme-root'), qr/SUCCESS/, 'request - 1'); + +ok(reload($t), 'reload'); + +like(get(8443, 'example.test', 'acme-root'), qr/SUCCESS/, 'request - 2'); + +############################################################################### + +sub get { + my ($port, $host, $ca) = @_; + + $ca = undef if $IO::Socket::SSL::VERSION < 2.062 + || !eval { Net::SSLeay::X509_V_FLAG_PARTIAL_CHAIN() }; + + http_get('/', + PeerAddr => '127.0.0.1:' . port($port), + SSL => 1, + $ca ? ( + SSL_ca_file => "$d/$ca.crt", + SSL_verifycn_name => $host, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_PEER(), + ) : () + ); +} + +sub reload { + my ($t) = @_; + + $t->reload(); + + for (1 .. 30) { + return 1 if $t->read_file('error.log') =~ /exited with code/; + select undef, undef, undef, 0.2; + } +} + +############################################################################### diff --git a/t/acme_renewal.t b/t/acme_renewal.t new file mode 100644 index 0000000..9027b9e --- /dev/null +++ b/t/acme_renewal.t @@ -0,0 +1,146 @@ +#!/usr/bin/perl + +# Copyright (c) F5, Inc. +# +# This source code is licensed under the Apache License, Version 2.0 license +# found in the LICENSE file in the root directory of this source tree. + +# Tests for ACME client: certificate renewal. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use IO::Select; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; +use Test::Nginx::ACME; +use Test::Nginx::DNS; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http http_ssl socket_ssl/) + ->has_daemon('openssl'); + +$t->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + resolver 127.0.0.1:%%PORT_8980_UDP%%; + + acme_issuer default { + uri https://acme.test:%%PORT_9000%%/dir; + ssl_trusted_certificate acme.test.crt; + state_path %%TESTDIR%%; + accept_terms_of_service; + } + + server { + listen 127.0.0.1:8080; + server_name example.test; + } + + server { + listen 127.0.0.1:8443 ssl; + server_name example.test; + + acme_certificate default; + + ssl_certificate $acme_certificate; + ssl_certificate_key $acme_certificate_key; + } +} + +EOF + +$t->write_file('openssl.conf', <testdir(); + +foreach my $name ('acme.test') { + system('openssl req -x509 -new ' + . "-config $d/openssl.conf -subj /CN=$name/ " + . "-out $d/$name.crt -keyout $d/$name.key " + . ">>$d/openssl.out 2>&1") == 0 + or die "Can't create certificate for $name: $!\n"; +} + +my $dp = port(8980, udp=>1); +my @dc = ( + { name => 'acme.test', A => '127.0.0.1' }, + { name => 'example.test', A => '127.0.0.1' } +); + +my $acme = Test::Nginx::ACME->new($t, port(9000), port(9001), + $t->testdir . '/acme.test.crt', + $t->testdir . '/acme.test.key', + http_port => port(8080), + tls_port => port(8443), + dns_port => $dp, + nosleep => 1, + validity => 30, +); + +$t->run_daemon(\&Test::Nginx::DNS::dns_test_daemon, $t, $dp, \@dc); +$t->waitforfile($t->testdir . '/' . $dp); + +$t->run_daemon(\&Test::Nginx::ACME::acme_test_daemon, $t, $acme); +$t->waitforsocket('127.0.0.1:' . $acme->port()); +$t->write_file('acme-root.crt', $acme->trusted_ca()); + +$t->write_file('index.html', 'SUCCESS'); +$t->plan(2)->run(); + +############################################################################### + +$acme->wait_certificate('example.test') or die "no certificate"; + +like(get(8443, 'example.test', 'acme-root'), qr/SUCCESS/, 'tls request 1'); + +select undef, undef, undef, 45; + +like(get(8443, 'example.test', 'acme-root'), qr/SUCCESS/, 'tls request 2'); + +############################################################################### + +sub get { + my ($port, $host, $ca) = @_; + + $ca = undef if $IO::Socket::SSL::VERSION < 2.062 + || !eval { Net::SSLeay::X509_V_FLAG_PARTIAL_CHAIN() }; + + http_get('/', + PeerAddr => '127.0.0.1:' . port($port), + SSL => 1, + $ca ? ( + SSL_ca_file => "$d/$ca.crt", + SSL_verifycn_name => $host, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_PEER(), + ) : () + ); +} + +############################################################################### diff --git a/t/acme_ssl_verify.t b/t/acme_ssl_verify.t new file mode 100644 index 0000000..43d5b9c --- /dev/null +++ b/t/acme_ssl_verify.t @@ -0,0 +1,139 @@ +#!/usr/bin/perl + +# Copyright (c) F5, Inc. +# +# This source code is licensed under the Apache License, Version 2.0 license +# found in the LICENSE file in the root directory of this source tree. + +# Tests for ACME client: ssl server verification option. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use IO::Select; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; +use Test::Nginx::ACME; +use Test::Nginx::DNS; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http http_ssl socket_ssl/) + ->has_daemon('openssl'); + +$t->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + resolver 127.0.0.1:%%PORT_8980_UDP%%; + + acme_issuer default { + uri https://127.0.0.1:%%PORT_9000%%/dir; + ssl_verify off; + state_path %%TESTDIR%%; + accept_terms_of_service; + } + + server { + listen 127.0.0.1:8080; + server_name example.test; + } + + server { + listen 127.0.0.1:8443 ssl; + server_name example.test; + + acme_certificate default; + + ssl_certificate $acme_certificate; + ssl_certificate_key $acme_certificate_key; + } +} + +EOF + +$t->write_file('openssl.conf', <testdir(); + +foreach my $name ('acme.test') { + system('openssl req -x509 -new ' + . "-config $d/openssl.conf -subj /CN=$name/ " + . "-out $d/$name.crt -keyout $d/$name.key " + . ">>$d/openssl.out 2>&1") == 0 + or die "Can't create certificate for $name: $!\n"; +} + +my $dp = port(8980, udp=>1); +my @dc = ( { name => 'example.test', A => '127.0.0.1' } ); + +my $acme = Test::Nginx::ACME->new($t, port(9000), port(9001), + $t->testdir . '/acme.test.crt', + $t->testdir . '/acme.test.key', + http_port => port(8080), + tls_port => port(8443), + dns_port => $dp, + nosleep => 1, + validity => 60, +); + +$t->run_daemon(\&Test::Nginx::DNS::dns_test_daemon, $t, $dp, \@dc); +$t->waitforfile($t->testdir . '/' . $dp); + +$t->run_daemon(\&Test::Nginx::ACME::acme_test_daemon, $t, $acme); +$t->waitforsocket('127.0.0.1:' . $acme->port()); +$t->write_file('acme-root.crt', $acme->trusted_ca()); + +$t->write_file('index.html', 'SUCCESS'); +$t->plan(1)->run(); + +############################################################################### + +$acme->wait_certificate('example.test') or die "no certificate"; + +like(get(8443, 'example.test', 'acme-root'), qr/SUCCESS/, 'tls request'); + +############################################################################### + +sub get { + my ($port, $host, $ca) = @_; + + $ca = undef if $IO::Socket::SSL::VERSION < 2.062 + || !eval { Net::SSLeay::X509_V_FLAG_PARTIAL_CHAIN() }; + + http_get('/', + PeerAddr => '127.0.0.1:' . port($port), + SSL => 1, + $ca ? ( + SSL_ca_file => "$d/$ca.crt", + SSL_verifycn_name => $host, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_PEER(), + ) : () + ); +} + +############################################################################### diff --git a/t/lib/Test/Nginx/ACME.pm b/t/lib/Test/Nginx/ACME.pm new file mode 100644 index 0000000..19c090e --- /dev/null +++ b/t/lib/Test/Nginx/ACME.pm @@ -0,0 +1,126 @@ +package Test::Nginx::ACME; + +# Copyright (c) F5, Inc. +# +# This source code is licensed under the Apache License, Version 2.0 license +# found in the LICENSE file in the root directory of this source tree. + +# Module for nginx ACME tests. + +############################################################################### + +use warnings; +use strict; + +use base qw/ Exporter /; +our @EXPORT_OK = qw/ acme_test_daemon /; + +use File::Spec; +use Test::Nginx qw//; + +our $PEBBLE = $ENV{TEST_NGINX_PEBBLE_BINARY} // 'pebble'; + +sub new { + my $self = {}; + bless $self, shift @_; + + my ($t, $port, $mgmt, $cert, $key, %extra) = @_; + + $t->has_daemon($PEBBLE); + + my $http_port = $extra{http_port} || Test::Nginx::port(8080); + my $tls_port = $extra{tls_port} || Test::Nginx::port(8443); + my $validity = $extra{validity} || 3600; + + $self->{dns_port} = $extra{dns_port} || Test::Nginx::port(8980, udp=>1); + $self->{noncereject} = $extra{noncereject}; + $self->{nosleep} = $extra{nosleep}; + + $self->{port} = $port; + $self->{mgmt} = $mgmt; + + $self->{state} = $extra{state} // $t->testdir(); + + $t->write_file("pebble-$port.json", <{port}; +} + +sub trusted_ca { + my $self = shift; + Test::Nginx::log_core('|| Fetching certificate from port ', $self->{mgmt}); + my $cert = _get_body($self->{mgmt}, '/roots/0'); + $cert =~ s/(BEGIN|END) C/$1 TRUSTED C/g; + $cert; +} + +sub wait_certificate { + my ($self, $cert, %extra) = @_; + + my $file = File::Spec->catfile($self->{'state'}, + '{www.,}' . $cert . '*.crt'); + + my $timeout = ($extra{'timeout'} // 20) * 5; + + for (1 .. $timeout) { + return 1 if defined glob($file); + select undef, undef, undef, 0.2; + } +} + +############################################################################### + +sub _get_body { + my ($port, $uri) = @_; + + my $r = Test::Nginx::http_get($uri, + PeerAddr => '127.0.0.1:' . $port, + SSL => 1, + ); + + return $r =~ /.*?\x0d\x0a?\x0d\x0a?(.*)/ms && $1; +} + +############################################################################### + +sub acme_test_daemon { + my ($t, $acme) = @_; + my $port = $acme->{port}; + my $dnsserver = '127.0.0.1:' . $acme->{dns_port}; + + $ENV{PEBBLE_VA_NOSLEEP} = 1 if $acme->{nosleep}; + $ENV{PEBBLE_WFE_NONCEREJECT} = $acme->{noncereject} if $acme->{noncereject}; + + open STDOUT, ">", $t->testdir . '/pebble-' . $port . '.out' + or die "Can't reopen STDOUT: $!"; + + open STDERR, ">", $t->testdir . '/pebble-' . $port . '.err' + or die "Can't reopen STDERR: $!"; + + exec($PEBBLE, '-config', $t->testdir . '/pebble-' . $port . '.json', + '-dnsserver', $dnsserver); +} + +############################################################################### + +1; + +############################################################################### diff --git a/t/lib/Test/Nginx/DNS.pm b/t/lib/Test/Nginx/DNS.pm new file mode 100644 index 0000000..192b700 --- /dev/null +++ b/t/lib/Test/Nginx/DNS.pm @@ -0,0 +1,177 @@ +package Test::Nginx::DNS; + +# Copyright (c) F5, Inc. +# +# This source code is licensed under the Apache License, Version 2.0 license +# found in the LICENSE file in the root directory of this source tree. + +# DNS server module for nginx tests. + +############################################################################### + +use warnings; +use strict; + +use base qw/ Exporter /; +our @EXPORT_OK = qw/ dns_test_daemon /; + +use Test::More qw//; +use IO::Select; +use IO::Socket; +use Socket qw/ CRLF /; + +use Test::Nginx qw//; + +use constant NOERROR => 0; +use constant FORMERR => 1; +use constant SERVFAIL => 2; +use constant NXDOMAIN => 3; + +use constant A => 1; +use constant CNAME => 5; +use constant PTR => 12; +use constant AAAA => 28; +use constant SRV => 33; + +use constant IN => 1; + + +############################################################################### + +sub reply_handler { + my ($recv_data, $zone) = @_; + + my (@name, @rdata); + + # default values + + my ($hdr, $rcode, $ttl) = (0x8180, NOERROR, 1); + + # decode name + + my ($len, $offset) = (undef, 12); + while (1) { + $len = unpack("\@$offset C", $recv_data); + last if $len == 0; + $offset++; + push @name, unpack("\@$offset A$len", $recv_data); + $offset += $len; + } + + $offset -= 1; + my ($id, $type, $class) = unpack("n x$offset n2", $recv_data); + + my $name = join('.', @name); + + foreach my $h (@$zone) { + next if (defined $h->{'name'} && $name ne $h->{'name'}); + next if (defined $h->{'match'} && $name !~ $h->{'match'}); + + if ($h->{ERROR}) { + $rcode = SERVFAIL; + + } elsif ($type == A && $h->{A}) { + push @rdata, rd_addr($ttl, $h->{A}); + + } elsif ($type == AAAA && $h->{AAAA}) { + push @rdata, rd_addr6($ttl, $h->{AAAA}); + + } elsif ($type == CNAME && $h->{CNAME}) { + push @rdata, rd_name(CNAME, $ttl, $h->{CNAME}); + + } elsif ($type == PTR && $h->{PTR}) { + push @rdata, rd_name(PTR, $ttl, $h->{PTR}); + + } elsif ($type == SRV && $h->{SRV}) { + push @rdata, rd_srv($ttl, (split ' ', $_)); + } + + last; + } + + Test::Nginx::log_core('||', "DNS: $name $type $rcode"); + + $len = @name; + pack("n6 (C/a*)$len x n2", $id, $hdr | $rcode, 1, scalar @rdata, + 0, 0, @name, $type, $class) . join('', @rdata); +} + +sub rd_addr { + my ($ttl, $addr) = @_; + + my $code = 'split(/\./, $addr)'; + + return pack 'n3N', 0xc00c, A, IN, $ttl if $addr eq ''; + + pack 'n3N nC4', 0xc00c, A, IN, $ttl, eval "scalar $code", eval($code); +} + +sub expand_ip6 { + my ($addr) = @_; + + substr ($addr, index($addr, "::"), 2) = + join "0", map { ":" } (0 .. 8 - (split /:/, $addr) + 1); + map { hex "0" x (4 - length $_) . "$_" } split /:/, $addr; +} + +sub rd_addr6 { + my ($ttl, $addr) = @_; + + pack 'n3N nn8', 0xc00c, AAAA, IN, $ttl, 16, expand_ip6($addr); +} + +sub rd_name { + my ($type, $ttl, $name) = @_; + my @rdname = split /\./, $name; + my $rdlen = length(join '', @rdname) + @rdname + 1; + + pack 'n3N n (C/a*)* x', 0xc00c, $type, IN, $ttl, $rdlen, @rdname; +} + +sub rd_srv { + my ($ttl, $pri, $w, $port, $name) = @_; + my @rdname = split /\./, $name; + my $rdlen = length(join '', @rdname) + @rdname + 7; # pri w port x + + pack 'n3N n n3 (C/a*)* x', + 0xc00c, SRV, IN, $ttl, $rdlen, $pri, $w, $port, @rdname; +} + +sub dns_test_daemon { + my ($t, $port, $h, %extra) = @_; + + my $handler = ref($h) eq 'CODE' ? $h : sub { reply_handler(@_, $h) }; + + my ($data, $recv_data); + my $socket = IO::Socket::INET->new( + LocalAddr => '127.0.0.1', + LocalPort => $port, + Proto => 'udp', + ) + or die "Can't create listening socket: $!\n"; + + my $sel = IO::Select->new($socket); + + local $SIG{PIPE} = 'IGNORE'; + + # signal we are ready + + open my $fh, '>', $t->testdir() . '/' . $port; + close $fh; + + while (my @ready = $sel->can_read) { + foreach my $fh (@ready) { + if ($socket == $fh) { + $fh->recv($recv_data, 65536); + $data = $handler->($recv_data); + $fh->send($data); + } + } + } +} + +############################################################################### + +1; + +###############################################################################