The Perl and Raku Conference 2025: Greenville, South Carolina - June 27-29 Learn more

# cpanel - t/lib/Cpanel/TestObj/ACMEServer.pm Copyright(c) 2016 cPanel, Inc.
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited
use strict;
use parent qw( Test::Class );
use FindBin;
use lib "$FindBin::Bin/lib";
use Digest::SHA ();
use File::Slurp ();
use File::Temp ();
use JSON ();
use Test::Crypt ();
sub _DIRECTORY {
my $host = t::Net::ACME::MockService::_HOST();
my %dir;
for my $ep (qw( new-authz new-cert new-reg )) {
$dir{$ep} = "https://$host/mock-acme/mock-$ep";
}
return \%dir;
}
sub _decode_jwt {
my ($jwt, $key) = @_;
Call::Context::must_be_list();
return Test::Crypt::decode_jwt(
key => $key,
token => $jwt,
);
}
sub _with_mocked_http_request {
my ($self, $acme_key, $endpoints_hr, $todo_as_client_cr ) = @_;
no warnings 'redefine';
local *HTTP::Tiny::request = sub {
my (undef, $method, $url, $args_hr) = @_;
my ($schema, $host, $after_host) = ($url =~ m<\A([^:]+)://([^/]+)(?:/(.*))?>);
$after_host ||= q<>;
my @matching_endpoints = grep { "$method:$after_host" =~ m<\Q$_\E> } keys %$endpoints_hr;
my $longest = (sort { length $a <=> length $b } @matching_endpoints)[-1];
my $postdata = $args_hr->{'content'};
my ( $header, $payload ) = $postdata ? _decode_jwt($postdata, $acme_key) : ();
my $rest_calls_ar = $self->_load_file_json( $self->{'_rest_calls_file'} );
local $ENV{'REQUEST_URI'} = "/$after_host";
local $ENV{'REQUEST_METHOD'} = $method;
$ENV{'REQUEST_METHOD'} =~ tr<a-z><A-Z>;
push @$rest_calls_ar, {
ENV => {
(map { $_ => $ENV{$_} } qw( REQUEST_URI REQUEST_METHOD ) ),
HTTPS => ($schema eq 'https' ? 'on' : q<>),
},
POST => $postdata,
};
$self->_dump_file_json( $self->{'_rest_calls_file'}, $rest_calls_ar );
my ($status, $reason, $headers_hr, $content);
if (!$longest) {
($status, $reason) = ( 404, 'Not Found (cP)' );
}
elsif ( $method eq 'post' ) {
if ( !$self->_check_nonce( $header->{'nonce'} ) ) {
diag explain [ "bad nonce in PID $$", $header->{'nonce'} ];
($status, $reason) = ( 400, 'Bad nonce (cP)' );
}
}
if (!$status) {
($status, $reason, $headers_hr, $content) = $endpoints_hr->{$longest}->($header, $payload);
}
my $nonce = Digest::SHA::sha256_hex( $$ . time . rand );
$self->_add_nonce($nonce);
$headers_hr->{'replay-nonce'} = $nonce;
if (ref $content) {
$content = JSON::encode_json($content);
}
#Normalize hash keys
$headers_hr->{lc $_} = delete $headers_hr->{$_} for keys %$headers_hr;
return {
method => $method,
url => $url,
status => $status,
reason => $reason,
content => $content,
headers => $headers_hr,
};
};
return $todo_as_client_cr->();
}
sub _dump_file_json {
my ($self, $path, $struct) = @_;
File::Slurp::write_file( $path, JSON::encode_json($struct) );
return;
}
sub _load_file_json {
my ($self, $path) = @_;
return JSON::decode_json( File::Slurp::read_file( $path ) );
}
#----------------------------------------------------------------------
sub _do_acme_server {
my ( $self, $acme_key, $todo_as_client_cr, ) = @_;
my $reg_dir = $self->{'_registrations_dir'};
$self->_with_mocked_http_request(
$acme_key,
{
'GET:directory' => sub { return $self->_server_send_directory() },
'GET:terms' => sub {
return( 200, 'OK', { Location => 'http://the.terms' } );
},
'POST:reg' => sub {
my ( $header, $payload ) = @_;
my ($reg_index) = ( $ENV{'REQUEST_URI'} =~ m<.+/(.+)> );
if ( -e "$reg_dir/$reg_index" ) {
my $reg = $self->_load_file_json("$reg_dir/$reg_index");
for my $rp (qw( contact agreement )) {
next if !exists $payload->{$rp};
$reg->{$rp} = $payload->{$rp};
}
$self->_dump_file_json( "$reg_dir/$reg_index", $reg );
return(
202 => 'Accepted',
{},
$reg,
);
}
else {
return(
400 => 'No registration (cP)',
{},
q<>,
);
}
},
'POST:mock-acme/mock-new-reg' => sub {
my ( $header, $payload ) = @_;
my $reg_index = Digest::SHA::sha256_hex( join( '.', @{ $header->{'jwk'} }{ 'n', 'e' } ) );
my $host = t::Net::ACME::MockService::_HOST();
my $reg_url = "https://$host/reg/$reg_index";
if ( -e "$reg_dir/$reg_index" ) {
return(
409 => 'Already! (cP)',
{
Location => $reg_url,
},
);
}
else {
my $reg_data = {
key => $header->{'jwk'},
contact => $payload->{'contacts'},
};
$self->_dump_file_json( "$reg_dir/$reg_index", $reg_data );
return (
201 => 'Created',
{
Location => $reg_url,
Link => '<http://cp-terms>;rel="terms-of-service"',
},
$reg_data,
);
}
},
'POST:mock-acme/mock-new-authz' => sub {
my ( $header, $payload ) = @_;
my $domain = $payload->{'identifier'}{'value'};
return(
201 => 'Created',
{
Location => 'https://authz/' . rand,
},
{
status => 'pending',
identifier => $payload->{'identifier'},
challenges => [
{
type => 'weird-01',
token => 'weird_token',
},
{
type => 'http-01',
token => 'http_challenge_token',
},
],
combinations => [ [0], [1] ],
},
);
},
},
$todo_as_client_cr,
);
return;
}
sub _reset_server : Tests(setup) {
my ($self) = @_;
#Store the nonces on disk … but is this necessary?
$self->{'_nonce_path'} = File::Temp::tempdir(CLEANUP => 1) . '/nonce';
(undef, $self->{'_rest_calls_file'}) = File::Temp::tempfile(CLEANUP => 1);
$self->_dump_file_json( $self->{'_rest_calls_file'}, [] );
return;
}
#TODO: Make this more flexible, and refactor it to a separate module.
#sub _do_json_rest_server {
# my ( $self, %opts ) = @_;
#
# die 'Don’t do “process_http_request” with this!' if $opts{'process_http_request'};
#
# my $endpoints_hr = $opts{'endpoints_hr'} or die 'Need “endpoints_hr”!';
#
# $self->{'_rest_calls_file'} = $self->tempfile();
# Cpanel::JSON::DumpFile( $self->{'_rest_calls_file'}, [] );
#
# #We have to store the nonces on disk because Net::Server::HTTP sometimes
# #reuses the same process.
# local $self->{'_nonce_path'} = $self->tempdir() . '/nonce';
#
# $opts{'process_http_request'} = sub {
# my ( $http_self, $handle, $postdata ) = @_;
#
# my $rest_calls_ar = Cpanel::JSON::LoadFile( $self->{'_rest_calls_file'} );
#
# push @$rest_calls_ar, {
# ENV => \%ENV,
# POST => $postdata,
# };
#
# Cpanel::JSON::DumpFile( $self->{'_rest_calls_file'}, $rest_calls_ar );
#
# my ( $header, $payload ) = $postdata ? _decode_jwt($postdata) : ();
#
# if ( $ENV{'REQUEST_METHOD'} eq 'POST' ) {
# if ( !$self->_check_nonce( $header->{'nonce'} ) ) {
# diag explain [ "bad nonce in PID $$", $header->{'nonce'} ];
# $self->_server_send_response( 400, reason => 'Bad nonce (cP)' );
# return;
# }
# }
#
# for my $ep ( keys %$endpoints_hr ) {
# if ( $ENV{'REQUEST_URI'} =~ qr<\A/$ep> ) {
# return $endpoints_hr->{$ep}->( $self, $postdata, $header, $payload );
# }
# }
#
# $self->_server_send_response(404);
# };
#
# return $self->_do_ssl_server(%opts);
#}
sub _server_send_directory {
my ($self) = @_;
return (
200 => 'OK',
{},
$self->_DIRECTORY(),
);
}
sub _get_rest_calls {
my ($self, $acme_key) = @_;
die 'list!' if !wantarray;
my @requests = @{ $self->_load_file_json( $self->{'_rest_calls_file'} ) };
for my $r (@requests) {
next if !$r->{'POST'};
$r->{'POST'} = [ _decode_jwt( $r->{'POST'}, $acme_key ) ];
}
return @requests;
}
#sub _server_send_response {
# my ( $self, $status, %opts ) = @_;
#
# my $nonce = Digest::SHA::sha256_hex( $$ . time . rand );
# $self->_add_nonce($nonce);
#
# $opts{'headers_ar'} ||= [];
# push @{ $opts{'headers_ar'} }, 'Replay-Nonce' => $nonce;
#
# return $self->SUPER::_server_send_response( $status, %opts );
#}
#NB: ACME’s replay protection works thus:
# - each server response includes a nonce
# - each request must include ONE of the nonces that have been sent
# - once used, a nonce can’t be reused
#
#This is subtly different from what was originally in mind (i.e., that
#each request must use the most recently sent nonce). It implies that GET
#requests do not need to send nonces, though each GET response will
#include a nonce that may be used.
sub _add_nonce {
my ( $self, $nonce ) = @_;
my $nonces_hr = ( -s $self->{'_nonce_path'} ) ? $self->_load_file_json( $self->{'_nonce_path'} ) : {};
$nonces_hr->{$nonce} = 1;
$self->_dump_file_json( $self->{'_nonce_path'}, $nonces_hr );
return;
}
sub _check_nonce {
my ( $self, $nonce ) = @_;
my $nonces_hr = $self->_load_file_json( $self->{'_nonce_path'} );
my $val = delete $nonces_hr->{$nonce};
$self->_dump_file_json( $self->{'_nonce_path'}, $nonces_hr );
return $val;
}
#----------------------------------------------------------------------
use parent qw( Net::ACME );
sub _HOST {
return 'some.far.away.host';
}
1;