diff --git a/README.md b/README.md index 0a4e3fd..2156165 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ The module implements following specifications: * [RFC8555] (Automatic Certificate Management Environment) with limitations: * Only HTTP-01 challenge type is supported - * External account binding is not supported [NGINX]: https://nginx.org/ [RFC8555]: https://www.rfc-editor.org/rfc/rfc8555.html @@ -179,6 +178,22 @@ regarding account issues. The `mailto:` scheme will be assumed unless specified explicitly. +### external_account_key + +**Syntax:** external_account_key `kid` `file` + +**Default:** - + +**Context:** acme_issuer + +A key identifier and a file with the MAC key for external account authorization +([RFC8555 ยง 7.3.4](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3.4)). + +The value `data:key` can be specified instead of the `file` to load the key +directly from the configuration without using intermediate files. + +In both cases, the key is expected to be encoded as base64url. + ### ssl_trusted_certificate **Syntax:** ssl_trusted_certificate `file` diff --git a/src/acme.rs b/src/acme.rs index 0a8104d..21f851f 100644 --- a/src/acme.rs +++ b/src/acme.rs @@ -166,9 +166,11 @@ where &self.key, self.account.as_deref(), &url.to_string(), - &nonce, + Some(&nonce), payload.as_ref(), - )?; + )? + .to_string(); + let req = http::Request::builder() .uri(url) .method(http::Method::POST) @@ -227,6 +229,12 @@ where pub async fn new_account(&mut self) -> Result { self.directory = self.get_directory().await?; + if self.directory.meta.external_account_required == Some(true) + && self.issuer.eab_key.is_none() + { + return Err(anyhow!("external account key required")); + } + // We validate that the strings are valid UTF-8 at configuration time. let contact: Vec<&str> = self .issuer @@ -235,9 +243,26 @@ where .map(|x| x.to_str()) .collect::>()?; + let external_account_binding = self.issuer.eab_key.as_ref().and_then(|x| { + let key = crate::jws::ShaWithHmacKey::new(&x.key, 256); + let kid = x.kid.to_str().ok()?; + + let payload = serde_json::to_vec(&self.key).ok()?; + + crate::jws::sign_jws( + &key, + Some(kid), + &self.directory.new_account.to_string(), + None, + &payload, + ) + .ok() + }); + let payload = types::AccountRequest { terms_of_service_agreed: self.issuer.accept_tos, contact, + external_account_binding, ..Default::default() }; diff --git a/src/acme/types.rs b/src/acme/types.rs index 8fb77ca..0f55ba3 100644 --- a/src/acme/types.rs +++ b/src/acme/types.rs @@ -73,7 +73,8 @@ pub struct AccountRequest<'a> { pub terms_of_service_agreed: Option, #[serde(skip_serializing_if = "Option::is_none")] pub only_return_existing: Option, - // external_account_binding: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub external_account_binding: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] diff --git a/src/conf.rs b/src/conf.rs index 3792056..5ea216e 100644 --- a/src/conf.rs +++ b/src/conf.rs @@ -7,10 +7,10 @@ use core::ffi::{c_char, c_void, CStr}; use core::{mem, ptr}; use nginx_sys::{ - ngx_command_t, ngx_conf_parse, ngx_conf_t, ngx_http_core_srv_conf_t, ngx_str_t, ngx_uint_t, - NGX_CONF_1MORE, NGX_CONF_BLOCK, NGX_CONF_FLAG, NGX_CONF_NOARGS, NGX_CONF_TAKE1, - NGX_HTTP_MAIN_CONF, NGX_HTTP_MAIN_CONF_OFFSET, NGX_HTTP_SRV_CONF, NGX_HTTP_SRV_CONF_OFFSET, - NGX_LOG_EMERG, + ngx_command_t, ngx_conf_parse, ngx_conf_t, ngx_decode_base64url, ngx_http_core_srv_conf_t, + ngx_str_t, ngx_uint_t, NGX_CONF_1MORE, NGX_CONF_BLOCK, NGX_CONF_FLAG, NGX_CONF_NOARGS, + NGX_CONF_TAKE1, NGX_CONF_TAKE2, NGX_HTTP_MAIN_CONF, NGX_HTTP_MAIN_CONF_OFFSET, + NGX_HTTP_SRV_CONF, NGX_HTTP_SRV_CONF_OFFSET, NGX_LOG_EMERG, }; use ngx::collections::Vec; use ngx::core::{Pool, Status, NGX_CONF_ERROR, NGX_CONF_OK}; @@ -79,7 +79,7 @@ pub static mut NGX_HTTP_ACME_COMMANDS: [ngx_command_t; 4] = [ ngx_command_t::empty(), ]; -static mut NGX_HTTP_ACME_ISSUER_COMMANDS: [ngx_command_t; 8] = [ +static mut NGX_HTTP_ACME_ISSUER_COMMANDS: [ngx_command_t; 9] = [ ngx_command_t { name: ngx_string!("uri"), type_: NGX_CONF_TAKE1 as ngx_uint_t, @@ -104,6 +104,14 @@ static mut NGX_HTTP_ACME_ISSUER_COMMANDS: [ngx_command_t; 8] = [ offset: 0, post: ptr::null_mut(), }, + ngx_command_t { + name: ngx_string!("external_account_key"), + type_: NGX_CONF_TAKE2 as ngx_uint_t, + set: Some(cmd_issuer_set_external_account_key), + conf: 0, + offset: 0, + post: ptr::null_mut(), + }, ngx_command_t { name: ngx_string!("ssl_trusted_certificate"), type_: NGX_CONF_TAKE1 as ngx_uint_t, @@ -383,6 +391,52 @@ extern "C" fn cmd_issuer_set_account_key( NGX_CONF_OK } +extern "C" fn cmd_issuer_set_external_account_key( + cf: *mut ngx_conf_t, + _cmd: *mut ngx_command_t, + conf: *mut c_void, +) -> *mut c_char { + let cf = unsafe { cf.as_mut().expect("cf") }; + let issuer = unsafe { conf.cast::().as_mut().expect("issuer conf") }; + + if issuer.eab_key.is_some() { + return NGX_CONF_DUPLICATE; + } + + let mut pool = cf.pool(); + // NGX_CONF_TAKE2 ensures that args contains 3 elements + let args = cf.args(); + + let mut encoded = if let Some(arg) = args[2].strip_prefix(b"data:") { + arg + } else { + match crate::util::read_to_ngx_str(cf, &args[2]) { + Ok(x) => x, + Err(e) => return cf.error(args[0], &e), + } + }; + + crate::util::ngx_str_trim(&mut encoded); + + let len = encoded.len.div_ceil(4) * 3; + let mut key = ngx_str_t { + data: pool.alloc_unaligned(len).cast(), + len, + }; + + if key.data.is_null() { + return NGX_CONF_ERROR; + } + + if !Status(unsafe { ngx_decode_base64url(&mut key, &mut encoded) }).is_ok() { + return c"invalid base64 encoded value".as_ptr().cast_mut(); + } + + issuer.eab_key = Some(issuer::ExternalAccountKey { kid: args[1], key }); + + NGX_CONF_OK +} + extern "C" fn cmd_issuer_set_uri( cf: *mut ngx_conf_t, _cmd: *mut ngx_command_t, diff --git a/src/conf/issuer.rs b/src/conf/issuer.rs index 00f104b..5c71df8 100644 --- a/src/conf/issuer.rs +++ b/src/conf/issuer.rs @@ -44,6 +44,7 @@ pub struct Issuer { pub uri: Uri, pub account_key: PrivateKey, pub contacts: Vec, + pub eab_key: Option, pub resolver: Option>, pub resolver_timeout: ngx_msec_t, pub ssl_trusted_certificate: ngx_str_t, @@ -58,6 +59,12 @@ pub struct Issuer { pub data: Option<&'static RwLock>, } +#[derive(Debug)] +pub struct ExternalAccountKey { + pub kid: ngx_str_t, + pub key: ngx_str_t, +} + #[derive(Debug, Error)] pub enum IssuerError { #[error("cannot load account key: {0}")] @@ -88,6 +95,7 @@ impl Issuer { uri: Default::default(), account_key: PrivateKey::Unset, contacts: Vec::new_in(alloc.clone()), + eab_key: None, resolver: None, resolver_timeout: NGX_CONF_UNSET_MSEC, ssl_trusted_certificate: ngx_str_t::empty(), diff --git a/src/jws.rs b/src/jws.rs index 4bb9fa0..a596f82 100644 --- a/src/jws.rs +++ b/src/jws.rs @@ -3,6 +3,7 @@ // 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::fmt; use std::borrow::ToOwned; use std::string::String; @@ -19,7 +20,8 @@ use thiserror::Error; #[derive(Serialize)] struct JwsHeader<'a, Jwk: JsonWebKey> { pub alg: &'a str, - pub nonce: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option<&'a str>, pub url: &'a str, // Per 8555 6.2, "jwk" and "kid" fields are mutually exclusive. #[serde(flatten)] @@ -33,6 +35,13 @@ enum JwsHeaderKey<'a, Jwk: JsonWebKey> { Kid { kid: &'a str }, } +#[derive(Debug, Serialize)] +pub struct SignedMessage { + protected: String, + payload: String, + signature: String, +} + #[derive(Debug, Error)] pub enum Error { #[error("serialize failed: {0}")] @@ -52,7 +61,12 @@ pub enum NewKeyError { pub trait JsonWebKey: Serialize { fn alg(&self) -> &str; fn compute_mac(&self, header: &[u8], payload: &[u8]) -> Result, Error>; - fn thumbprint(&self) -> Result; + + /// Returns a key thumbprint, as defined in RFC7638 + fn thumbprint(&self) -> Result { + let data = serde_json::to_vec(self)?; + Ok(base64url(openssl::sha::sha256(&data))) + } } #[derive(Debug)] @@ -61,6 +75,11 @@ pub(crate) struct ShaWithEcdsaKey(PKey); #[derive(Debug)] pub(crate) struct ShaWithRsaKey(PKey); +#[derive(Debug)] +pub(crate) struct ShaWithHmacKey(T, u16) +where + T: AsRef<[u8]>; + #[inline] pub fn base64url>(buf: T) -> String { base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, buf) @@ -70,9 +89,9 @@ pub fn sign_jws( jwk: &Jwk, kid: Option<&str>, url: &str, - nonce: &str, + nonce: Option<&str>, payload: &[u8], -) -> Result { +) -> Result { let key = match kid { Some(kid) => JwsHeaderKey::Kid { kid }, None => JwsHeaderKey::Jwk { jwk }, @@ -86,14 +105,27 @@ pub fn sign_jws( }; let header_json = serde_json::to_vec(&header)?; - let header = base64url(&header_json); + + let protected = base64url(&header_json); let payload = base64url(payload); - let signature = jwk.compute_mac(header.as_bytes(), payload.as_bytes())?; + let signature = jwk.compute_mac(protected.as_bytes(), payload.as_bytes())?; let signature = base64url(signature); - Ok(std::format!( - r#"{{"protected":"{header}","payload":"{payload}","signature":"{signature}"}}"# - )) + Ok(SignedMessage { + protected, + payload, + signature, + }) +} + +impl fmt::Display for SignedMessage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + r#"{{"protected":"{}","payload":"{}","signature":"{}"}}"#, + self.protected, self.payload, self.signature + ) + } } impl JsonWebKey for ShaWithEcdsaKey { @@ -134,11 +166,6 @@ impl JsonWebKey for ShaWithEcdsaKey { Ok(buf) } - - fn thumbprint(&self) -> Result { - let data = serde_json::to_vec(self)?; - Ok(base64url(openssl::sha::sha256(&data))) - } } impl Serialize for ShaWithEcdsaKey { @@ -215,11 +242,6 @@ impl JsonWebKey for ShaWithRsaKey { Ok(buf) } - - fn thumbprint(&self) -> Result { - let data = serde_json::to_vec(self)?; - Ok(base64url(openssl::sha::sha256(&data))) - } } impl Serialize for ShaWithRsaKey { @@ -260,6 +282,67 @@ impl TryFrom<&PKeyRef> for ShaWithRsaKey { } } +impl JsonWebKey for ShaWithHmacKey +where + T: AsRef<[u8]>, +{ + fn alg(&self) -> &str { + match self.1 { + 256 => "HS256", + 384 => "HS384", + 512 => "HS512", + _ => unreachable!("unsupported digest"), + } + } + + fn compute_mac(&self, header: &[u8], payload: &[u8]) -> Result, Error> { + let md = match self.1 { + 384 => openssl::hash::MessageDigest::sha384(), + 512 => openssl::hash::MessageDigest::sha512(), + _ => openssl::hash::MessageDigest::sha256(), + }; + + // Cannot use Signer here because BoringSSL does not provide `EVP_PKEY_new_from_mac`. + let mut inbuf = Vec::with_capacity(header.len() + payload.len() + 1); + inbuf.extend_from_slice(header); + inbuf.push(b'.'); + inbuf.extend_from_slice(payload); + + let mut buf = vec![0u8; md.size()]; + + let len = hmac(&md, self.0.as_ref(), &inbuf, &mut buf)?; + buf.truncate(len); + + Ok(buf) + } +} + +impl Serialize for ShaWithHmacKey +where + T: AsRef<[u8]>, +{ + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let k = base64url(self.0.as_ref()); + let mut map = serializer.serialize_map(Some(2))?; + // order is important for thumbprint generation (RFC7638) + map.serialize_entry("k", &k)?; + map.serialize_entry("kty", "oct")?; + map.end() + } +} + +impl ShaWithHmacKey +where + T: AsRef<[u8]>, +{ + pub fn new(key: T, bits: u16) -> Self { + Self(key, bits) + } +} + /// [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()); @@ -280,3 +363,31 @@ fn bn2binpad<'a>(bn: &BigNumRef, out: &'a mut [u8]) -> Result<&'a [u8], ErrorSta Err(ErrorStack::get()) } } + +fn hmac( + digest: &openssl::hash::MessageDigest, + key: &[u8], + data: &[u8], + out: &mut [u8], +) -> Result { + debug_assert!(out.len() >= digest.size()); + + let mut len: core::ffi::c_uint = 0; + let p = unsafe { + openssl_sys::HMAC( + digest.as_ptr(), + key.as_ptr().cast(), + key.len() as _, + data.as_ptr(), + data.len(), + out.as_mut_ptr(), + &mut len, + ) + }; + + if p.is_null() { + Err(ErrorStack::get()) + } else { + Ok(len as _) + } +} diff --git a/src/util.rs b/src/util.rs index 7248331..74a77ba 100644 --- a/src/util.rs +++ b/src/util.rs @@ -5,11 +5,13 @@ use core::ops::{Deref, DerefMut}; use core::ptr::NonNull; +use std::io::{self, Read}; -use nginx_sys::{ngx_log_t, ngx_uint_t}; +use nginx_sys::{ngx_conf_full_name, ngx_conf_t, ngx_log_t, ngx_pool_t, ngx_str_t, ngx_uint_t}; use ngx::allocator::AllocError; -use ngx::core::Pool; -use ngx::ffi::ngx_pool_t; +use ngx::core::{Pool, Status}; + +use crate::conf::ext::NgxConfExt; #[derive(Clone, Debug, Eq, PartialEq)] pub enum NgxProcess { @@ -32,6 +34,53 @@ pub fn ngx_process() -> NgxProcess { _ => unreachable!("unknown process type {}", process), } } + +pub fn read_to_ngx_str(cf: &ngx_conf_t, path: &ngx_str_t) -> Result { + let mut path = *path; + if !Status(unsafe { ngx_conf_full_name(cf.cycle, &mut path, 1) }).is_ok() { + return Err(io::ErrorKind::OutOfMemory.into()); + }; + + let path = path.to_str().map_err(io::Error::other)?; + let mut file = std::fs::File::open(path)?; + + let buf = match file.metadata().map(|x| x.len() as usize) { + Ok(len) => { + let mut buf = ngx_str_t { + data: cf.pool().alloc_unaligned(len).cast(), + len, + }; + if buf.data.is_null() { + return Err(io::ErrorKind::OutOfMemory.into()); + } + + file.read_exact(buf.as_bytes_mut())?; + buf + } + _ => { + let mut buf = std::vec::Vec::new(); + file.read_to_end(&mut buf)?; + + unsafe { ngx_str_t::from_bytes(cf.pool, &buf) }.ok_or(io::ErrorKind::OutOfMemory)? + } + }; + + Ok(buf) +} + +pub fn ngx_str_trim(val: &mut ngx_str_t) { + let b = val.as_bytes(); + let start = b.iter().take_while(|x| x.is_ascii_whitespace()).count(); + let end = b + .iter() + .rev() + .take_while(|x| x.is_ascii_whitespace()) + .count(); + + val.len -= start + end; + val.data = unsafe { val.data.add(start) }; +} + pub struct OwnedPool(Pool); impl OwnedPool { pub fn new(size: usize, log: NonNull) -> Result { diff --git a/t/acme_external_account.t b/t/acme_external_account.t new file mode 100644 index 0000000..c6ac279 --- /dev/null +++ b/t/acme_external_account.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: External Account Binding support. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +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; + external_account_key eab-kid eab-secret; + 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 $eab_key = 'zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W'; + +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, + conf => { + externalAccountBindingRequired => \1, + externalAccountMACKeys => { 'eab-kid' => $eab_key }, + } +)->has(qw/eab/); + +$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('eab-secret', $eab_key); + +$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 index 9ce6861..3316716 100644 --- a/t/lib/Test/Nginx/ACME.pm +++ b/t/lib/Test/Nginx/ACME.pm @@ -16,10 +16,21 @@ use base qw/ Exporter /; our @EXPORT_OK = qw/ acme_test_daemon /; use File::Spec; +use Test::More qw//; + use Test::Nginx qw//; +eval { require JSON::PP; }; +Test::More::plan(skip_all => "JSON::PP not installed") if $@; + our $PEBBLE = $ENV{TEST_NGINX_PEBBLE_BINARY} // 'pebble'; +my %features = ( + 'eab' => '2.5.2', # broken in 2.5.0 + 'profile' => '2.7.0', + 'validity' => '2.4.0', +); + sub new { my $self = {}; bless $self, shift @_; @@ -41,26 +52,28 @@ sub new { $self->{state} = $extra{state} // $t->testdir(); - $t->write_file("pebble-$port.json", < '127.0.0.1:' . $port, + managementListenAddress => '127.0.0.1:' . $mgmt, + certificate => $cert, + privateKey => $key, + httpPort => $http_port + 0, + tlsPort => $tls_port + 0, + ocspResponderURL => '', + certificateValidityPeriod => $validity + 0, + profiles => { + default => { + validityPeriod => $validity + 0, + } + }, + ); + + # merge custom configuration + + @conf { keys %{$extra{conf}} } = values %{$extra{conf}}; + + my $conf = JSON::PP->new()->canonical()->encode({ pebble => \%conf }); + $t->write_file("pebble-$port.json", $conf); return $self; } @@ -72,7 +85,7 @@ sub port { sub trusted_ca { my $self = shift; - Test::Nginx::log_core('|| Fetching certificate from port ', $self->{mgmt}); + Test::Nginx::log_core('|| ACME: get certificate from', $self->{mgmt}); my $cert = _get_body($self->{mgmt}, '/roots/0'); $cert =~ s/(BEGIN|END) C/$1 TRUSTED C/g; $cert; @@ -92,8 +105,59 @@ sub wait_certificate { } } +sub has { + my ($self, @requested) = @_; + + foreach my $feature (@requested) { + Test::More::plan(skip_all => "no $feature available") + unless $self->has_feature($feature); + } + + return $self; +} + +sub has_feature { + my ($self, $feature) = @_; + my $ver; + + if (defined $features{$feature}) { + $ver = $features{$feature}; + } elsif ($feature =~ /^pebble:([\d.]+)$/) { + $ver = $1; + } else { + return 0; + } + + $self->{_version} //= _pebble_version(); + return 0 unless $self->{_version}; + + my @v = split(/\./, $self->{_version}); + my ($n, $v); + + for my $n (split(/\./, $ver)) { + $v = shift @v || 0; + return 0 if $n > $v; + return 1 if $v > $n; + } + + return 1; +} + ############################################################################### +sub _pebble_version { + my $ver = `$PEBBLE -version 2>&1`; + + if ($ver =~ /version: v?([\d.]+)/) { + Test::Nginx::log_core('|| ACME: pebble version', $1); + return $1; + } elsif (defined $ver) { + # The binary is available, but does not have the version info. + Test::Nginx::log_core('|| ACME: pebble version unknown'); + return '0'; + } +} + sub _get_body { my ($port, $uri) = @_; @@ -113,7 +177,8 @@ sub acme_test_daemon { 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}; + $ENV{PEBBLE_WFE_NONCEREJECT} = + $acme->{noncereject} if $acme->{noncereject}; open STDOUT, ">", $t->testdir . '/pebble-' . $port . '.out' or die "Can't reopen STDOUT: $!";