[pve-devel] [Patch V2 common 1/3] Move the code to proxmox-acme and add a dependency on it.

Fabian Grünbichler f.gruenbichler at proxmox.com
Wed Apr 1 15:25:36 CEST 2020


note: this one requires a breaks+replaces on the other side 
(proxmox-acme), and a version bump here (so that proxmox-acme can have 
an appropriate versioned depends).

since the other two pve-common are independent I already applied them - 
otherwise this one should have probably been 3/3 ;)

On March 31, 2020 12:08 pm, Wolfgang Link wrote:
> Signed-off-by: Wolfgang Link <w.link at proxmox.com>
> ---
>  debian/control             |   1 +
>  src/PVE/ACME.pm            | 533 -------------------------------------
>  src/PVE/ACME/Challenge.pm  |  22 --
>  src/PVE/ACME/StandAlone.pm |  71 -----
>  4 files changed, 1 insertion(+), 626 deletions(-)
>  delete mode 100644 src/PVE/ACME.pm
>  delete mode 100644 src/PVE/ACME/Challenge.pm
>  delete mode 100644 src/PVE/ACME/StandAlone.pm
> 
> diff --git a/debian/control b/debian/control
> index c467dd6..8faa22e 100644
> --- a/debian/control
> +++ b/debian/control
> @@ -32,6 +32,7 @@ Depends: libclone-perl,
>           libstring-shellquote-perl,
>           liburi-perl,
>           libwww-perl,
> +	 libproxmox-acme-perl,
>           ${misc:Depends},
>           ${perl:Depends},
>  Breaks: ifupdown2 (<< 2.0.1-1+pve5),
> diff --git a/src/PVE/ACME.pm b/src/PVE/ACME.pm
> deleted file mode 100644
> index 114eb41..0000000
> --- a/src/PVE/ACME.pm
> +++ /dev/null
> @@ -1,533 +0,0 @@
> -package PVE::ACME;
> -
> -use strict;
> -use warnings;
> -
> -use POSIX;
> -
> -use Data::Dumper;
> -use Date::Parse;
> -use MIME::Base64 qw(encode_base64url);
> -use File::Path qw(make_path);
> -use JSON;
> -use Digest::SHA qw(sha256 sha256_hex);
> -
> -use HTTP::Request;
> -use LWP::UserAgent;
> -
> -use Crypt::OpenSSL::RSA;
> -
> -use PVE::Certificate;
> -use PVE::Tools qw(
> -file_set_contents
> -file_get_contents
> -);
> -
> -Crypt::OpenSSL::RSA->import_random_seed();
> -
> -my $LETSENCRYPT_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory';
> -
> -### ACME library (compatible with Let's Encrypt v2 API)
> -#
> -# sample usage:
> -#
> -# 1) my $acme = PVE::ACME->new('path/to/account.json', 'API directory URL');
> -# 2) $acme->init(4096); # generate account key
> -# 4) my $tos_url = $acme->get_meta()->{termsOfService}; # optional, display if applicable
> -# 5) $acme->new_account($tos_url, contact => ['mailto:example at example.com']);
> -#
> -# 1) my $acme = PVE::ACME->new('path/to/account.json', 'API directory URL');
> -# 2) $acme->load();
> -# 3) my ($order_url, $order) = $acme->new_order(['foo.example.com', 'bar.example.com']);
> -# 4) # repeat a-f for each $auth_url in $order->{authorizations}
> -# a) my $authorization = $acme->get_authorization($auth_url);
> -# b) # pick $challenge from $authorization->{challenges} according to desired type
> -# c) my $key_auth = $acme->key_authorization($challenge->{token});
> -# d) # setup challenge validation according to specification
> -# e) $acme->request_challenge_validation($challenge->{url}, $key_auth);
> -# f) # poll $acme->get_authorization($auth_url) until status is 'valid'
> -# 5) # generate CSR in PEM format
> -# 6) $acme->finalize_order($order, $csr);
> -# 7) # poll $acme->get_order($order_url) until status is 'valid'
> -# 8) my $cert = $acme->get_certificate($order);
> -# 9) # $key is path to key file, $cert contains PEM-encoded certificate chain
> -#
> -# 1) my $acme = PVE::ACME->new('path/to/account.json', 'API directory URL');
> -# 2) $acme->load();
> -# 3) $acme->revoke_certificate($cert);
> -
> -# Tools
> -sub encode($) { # acme requires 'base64url' encoding
> -    return encode_base64url($_[0]);
> -}
> -
> -sub tojs($;%) { # shortcut for to_json with utf8=>1
> -    my ($data, %data) = @_;
> -    return to_json($data, { utf8 => 1, %data });
> -}
> -
> -sub fromjs($) {
> -    return from_json($_[0]);
> -}
> -
> -sub fatal($$;$$) {
> -    my ($self, $msg, $dump, $noerr) = @_;
> -
> -    warn Dumper($dump), "\n" if $self->{debug} && $dump;
> -    if ($noerr) {
> -	warn "$msg\n";
> -    } else {
> -	die "$msg\n";
> -    }
> -}
> -
> -# Implementation
> -
> -# $path: account JSON file
> -# $directory: the ACME directory URL used to find method URLs
> -sub new($$$) {
> -    my ($class, $path, $directory) = @_;
> -
> -    $directory //= $LETSENCRYPT_STAGING;
> -
> -    my $ua = LWP::UserAgent->new();
> -    $ua->env_proxy();
> -    $ua->agent('pve-acme/0.1');
> -    $ua->protocols_allowed(['https']);
> -
> -    my $self = {
> -	ua => $ua,
> -	path => $path,
> -	directory => $directory,
> -	nonce => undef,
> -	key => undef,
> -	location => undef,
> -	account => undef,
> -	tos => undef,
> -    };
> -
> -    return bless $self, $class;
> -}
> -
> -# RS256: PKCS#1 padding, no OAEP, SHA256
> -my $configure_key = sub {
> -    my ($key) = @_;
> -    $key->use_pkcs1_padding();
> -    $key->use_sha256_hash();
> -};
> -
> -# Create account key with $keybits bits
> -# use instead of load, overwrites existing account JSON file!
> -sub init {
> -    my ($self, $keybits) = @_;
> -    die "Already have a key\n" if defined($self->{key});
> -    $keybits //= 4096;
> -    my $key = Crypt::OpenSSL::RSA->generate_key($keybits);
> -    $configure_key->($key);
> -    $self->{key} = $key;
> -    $self->save();
> -}
> -
> -my @SAVED_VALUES = qw(location account tos debug directory);
> -# Serialize persistent parts of $self to $self->{path} as JSON
> -sub save {
> -    my ($self) = @_;
> -    my $o = {};
> -    my $keystr;
> -    if (my $key = $self->{key}) {
> -	$keystr = $key->get_private_key_string();
> -	$o->{key} = $keystr;
> -    }
> -    for my $k (@SAVED_VALUES) {
> -	my $v = $self->{$k} // next;
> -	$o->{$k} = $v;
> -    }
> -    # pretty => 1 for readability
> -    # canonical => 1 to reduce churn
> -    file_set_contents($self->{path}, tojs($o, pretty => 1, canonical => 1));
> -}
> -
> -# Load serialized account JSON file into $self
> -sub load {
> -    my ($self) = @_;
> -    return if $self->{loaded};
> -    $self->{loaded} = 1;
> -    my $raw = file_get_contents($self->{path});
> -    if ($raw =~ m/^(.*)$/s) { $raw = $1; }  # untaint
> -    my $data = fromjs($raw);
> -    $self->{$_} = $data->{$_} for @SAVED_VALUES;
> -    if (defined(my $keystr = $data->{key})) {
> -	my $key = Crypt::OpenSSL::RSA->new_private_key($keystr);
> -	$configure_key->($key);
> -	$self->{key} = $key;
> -    }
> -}
> -
> -# The 'jwk' object needs the key type, key parameters and the usage,
> -# except for when we want to take the JWK-Thumbprint, then the usage
> -# must not be included.
> -sub jwk {
> -    my ($self, $pure) = @_;
> -    my $key = $self->{key}
> -	or die "No key was generated yet\n";
> -    my ($n, $e) = $key->get_key_parameters();
> -    return {
> -	kty => 'RSA',
> -	($pure ? () : (use => 'sig')), # for thumbprints
> -	n => encode($n->to_bin),
> -	e => encode($e->to_bin),
> -    };
> -}
> -
> -# The thumbprint is a sha256 hash of the lexicographically sorted (iow.
> -# canonical) condensed json string of the JWK object which gets base64url
> -# encoded.
> -sub jwk_thumbprint {
> -    my ($self) = @_;
> -    my $jwk = $self->jwk(1); # $pure = 1
> -    return encode(sha256(tojs($jwk, canonical=>1))); # canonical sorts
> -}
> -
> -# A key authorization string in acme is a challenge token dot-connected with
> -# a JWK Thumbprint. You put the base64url encoded sha256-hash of this string
> -# into the DNS TXT record.
> -sub key_authorization {
> -    my ($self, $token) = @_;
> -    return $token .'.'. $self->jwk_thumbprint();
> -}
> -
> -# JWS signing using the RS256 alg (RSA/SHA256).
> -sub jws {
> -    my ($self, $use_jwk, $data, $url) = @_;
> -    my $key = $self->{key}
> -	or die "No key was generated yet\n";
> -
> -    my $payload = $data ne '' ? encode(tojs($data)) : $data;
> -
> -    if (!defined($self->{nonce})) {
> -	my $method = $self->_method('newNonce');
> -	$self->do(GET => $method);
> -    }
> -
> -    # The acme protocol requires the actual request URL be in the protected
> -    # header. There is no unprotected header.
> -    my $protected = {
> -	alg => 'RS256',
> -	url => $url,
> -	nonce => $self->{nonce} // die "missing nonce\n"
> -    };
> -
> -    # header contains either
> -    # - kid, reference to account URL
> -    # - jwk, key itself
> -    # the latter is only allowed for
> -    # - creating accounts (no account URL yet)
> -    # - revoking certificates with the certificate key instead of account key
> -    if ($use_jwk) {
> -	$protected->{jwk} = $self->jwk();
> -    } else {
> -	$protected->{kid} = $self->{location};
> -    }
> -
> -    $protected = encode(tojs($protected));
> -
> -    my $signdata = "$protected.$payload";
> -    my $signature = encode($key->sign($signdata));
> -
> -    return {
> -	protected => $protected,
> -	payload => $payload,
> -	signature => $signature,
> -    };
> -}
> -
> -sub __get_result {
> -    my ($resp, $code, $plain) = @_;
> -
> -    die "expected code '$code', received '".$resp->code."'\n"
> -	if $resp->code != $code;
> -
> -    return $plain ? $resp->decoded_content : fromjs($resp->decoded_content);
> -}
> -
> -# Get the list of method URLs and query the directory if we have to.
> -sub __get_methods {
> -    my ($self) = @_;
> -    if (my $methods = $self->{methods}) {
> -	return $methods;
> -    }
> -    my $r = $self->do(GET => $self->{directory});
> -    my $methods = __get_result($r, 200);
> -    $self->fatal("unable to decode methods returned by directory - $@", $r) if $@;
> -    return ($self->{methods} = $methods);
> -}
> -
> -# Get a method, causing the directory to be queried first if necessary.
> -sub _method {
> -    my ($self, $method) = @_;
> -    my $methods = $self->__get_methods();
> -    my $url = $methods->{$method}
> -	or die "no such method: $method\n";
> -    return $url;
> -}
> -
> -# Get $self->{account} with an error if we don't have one yet.
> -sub _account {
> -    my ($self) = @_;
> -    my $account = $self->{account}
> -	// die "no account loaded\n";
> -    return wantarray ? ($account, $self->{location}) : $account;
> -}
> -
> -# debugging info
> -sub list_methods {
> -    my ($self) = @_;
> -    my $methods = $self->__get_methods();
> -    if (my $meta = $methods->{meta}) {
> -	print("(meta): $_ : $meta->{$_}\n") for sort keys %$meta;
> -    }
> -    print("$_ : $methods->{$_}\n") for sort grep {$_ ne 'meta'} keys %$methods;
> -}
> -
> -# return (optional) meta directory entry.
> -# this is public because it might contain the ToS, which should be displayed
> -# and agreed to before creating an account
> -sub get_meta {
> -    my ($self) = @_;
> -    my $methods = $self->__get_methods();
> -    return $methods->{meta};
> -}
> -
> -# Common code between new_account and update_account
> -sub __new_account {
> -    my ($self, $expected_code, $url, $new, %info) = @_;
> -    my $req = {
> -	%info,
> -    };
> -    my $r = $self->do(POST => $url, $req, $new);
> -    eval {
> -	my $account = __get_result($r, $expected_code);
> -	if (!defined($self->{location})) {
> -	    my $account_url = $r->header('Location')
> -		or die "did not receive an account URL\n";
> -	    $self->{location} = $account_url;
> -	}
> -	$self->{account} = $account;
> -	$self->save();
> -    };
> -    $self->fatal("POST to '$url' failed - $@", $r) if $@;
> -    return $self->{account};
> -}
> -
> -# Create a new account using data in %info.
> -# Optionally pass $tos_url to agree to the given Terms of Service
> -# POST to newAccount endpoint
> -# Expects a '201 Created' reply
> -# Saves and returns the account data
> -sub new_account {
> -    my ($self, $tos_url, %info) = @_;
> -    my $url = $self->_method('newAccount');
> -
> -    if ($tos_url) {
> -	$self->{tos} = $tos_url;
> -	$info{termsOfServiceAgreed} = JSON::true;
> -    }
> -
> -    return $self->__new_account(201, $url, 1, %info);
> -}
> -
> -# Update existing account with new %info
> -# POST to account URL
> -# Expects a '200 OK' reply
> -# Saves and returns updated account data
> -sub update_account {
> -    my ($self, %info) = @_;
> -    my (undef, $url) = $self->_account;
> -
> -    return $self->__new_account(200, $url, 0, %info);
> -}
> -
> -# Retrieves existing account information
> -# POST to account URL with empty body!
> -# Expects a '200 OK' reply
> -# Saves and returns updated account data
> -sub get_account {
> -    my ($self) = @_;
> -    return $self->update_account();
> -}
> -
> -# Start a new order for one or more domains
> -# POST to newOrder endpoint
> -# Expects a '201 Created' reply
> -# returns order URL and parsed order object, including authorization and finalize URLs
> -sub new_order {
> -    my ($self, $domains) = @_;
> -
> -    my $url = $self->_method('newOrder');
> -    my $req = {
> -	identifiers => [ map { { type => 'dns', value => $_ } } @$domains ],
> -    };
> -
> -    my $r = $self->do(POST => $url, $req);
> -    my ($order_url, $order);
> -    eval {
> -	$order_url = $r->header('Location')
> -	    or die "did not receive an order URL\n";
> -	$order = __get_result($r, 201)
> -    };
> -    $self->fatal("POST to '$url' failed - $@", $r) if $@;
> -    return ($order_url, $order);
> -}
> -
> -# Finalize order after all challenges have been validated
> -# POST to order's finalize URL
> -# Expects a '200 OK' reply
> -# returns (potentially updated) order object
> -sub finalize_order {
> -    my ($self, $order, $csr) = @_;
> -
> -    my $req = {
> -	csr => encode($csr),
> -    };
> -    my $r = $self->do(POST => $order->{finalize}, $req);
> -    my $return = eval { __get_result($r, 200); };
> -    $self->fatal("POST to '$order->{finalize}' failed - $@", $r) if $@;
> -    return $return;
> -}
> -
> -# Get order status
> -# GET-as-POST to order URL
> -# Expects a '200 OK' reply
> -# returns order object
> -sub get_order {
> -    my ($self, $order_url) = @_;
> -    my $r = $self->do(POST => $order_url, '');
> -    my $return = eval { __get_result($r, 200); };
> -    $self->fatal("POST of '$order_url' failed - $@", $r) if $@;
> -    return $return;
> -}
> -
> -# Gets authorization object
> -# GET-as-POST to authorization URL
> -# Expects a '200 OK' reply
> -# returns authorization object, including challenges array
> -sub get_authorization {
> -    my ($self, $auth_url) = @_;
> -
> -    my $r = $self->do(POST => $auth_url, '');
> -    my $return = eval { __get_result($r, 200); };
> -    $self->fatal("POST of '$auth_url' failed - $@", $r) if $@;
> -    return $return;
> -}
> -
> -# Deactivates existing authorization
> -# POST to authorization URL
> -# Expects a '200 OK' reply
> -# returns updated authorization object
> -sub deactivate_authorization {
> -    my ($self, $auth_url) = @_;
> -
> -    my $req = {
> -	status => 'deactivated',
> -    };
> -    my $r = $self->do(POST => $auth_url, $req);
> -    my $return = eval { __get_result($r, 200); };
> -    $self->fatal("POST to '$auth_url' failed - $@", $r) if $@;
> -    return $return;
> -}
> -
> -# Get certificate
> -# GET-as-POST to order's certificate URL
> -# Expects a '200 OK' reply
> -# returns certificate chain in PEM format
> -sub get_certificate {
> -    my ($self, $order) = @_;
> -
> -    $self->fatal("no certificate URL available (yet?)", $order)
> -       if !$order->{certificate};
> -
> -    my $r = $self->do(POST => $order->{certificate}, '');
> -    my $return = eval { __get_result($r, 200, 1); };
> -    $self->fatal("POST of '$order->{certificate}' failed - $@", $r) if $@;
> -    return $return;
> -}
> -
> -# Revoke given certificate
> -# POST to revokeCert endpoint
> -# currently only supports revokation with account key
> -# $certificate can either be PEM or DER encoded
> -# Expects a '200 OK' reply
> -sub revoke_certificate {
> -    my ($self, $certificate, $reason) = @_;
> -
> -    my $url = $self->_method('revokeCert');
> -
> -    if ($certificate =~ /^-----BEGIN CERTIFICATE-----/) {
> -	$certificate = PVE::Certificate::pem_to_der($certificate);
> -    }
> -
> -    my $req = {
> -	certificate => encode($certificate),
> -	reason => $reason // 0,
> -    };
> -    # TODO: set use_jwk if revoking with certificate key
> -    my $r = $self->do(POST => $url, $req);
> -    eval {
> -	die "unexpected code $r->code\n" if $r->code != 200;
> -    };
> -    $self->fatal("POST to '$url' failed - $@", $r) if $@;
> -}
> -
> -# Request validation of challenge
> -# POST to challenge URL
> -# call after validation has been setup
> -# returns (potentially updated) challenge object
> -sub request_challenge_validation {
> -    my ($self, $url, $key_authorization) = @_;
> -
> -    my $req = { keyAuthorization => $key_authorization };
> -
> -    my $r = $self->do(POST => $url, $req);
> -    my $return = eval { __get_result($r, 200); };
> -    $self->fatal("POST to '$url' failed - $@", $r) if $@;
> -    return $return;
> -}
> -
> -# actually 'do' a $method request on $url
> -# $data: input for JWS, optional
> -# $use_jwk: use JWK instead of KID in JWD (see sub jws)
> -sub do {
> -    my ($self, $method, $url, $data, $use_jwk) = @_;
> -
> -    $self->fatal("Error: can't $method to empty URL") if !$url || $url eq '';
> -
> -    my $headers = HTTP::Headers->new();
> -    $headers->header('Content-Type' => 'application/jose+json');
> -    my $content = defined($data) ? $self->jws($use_jwk, $data, $url) : undef;
> -    my $request;
> -    if (defined($content)) {
> -	$content = tojs($content);
> -	$request = HTTP::Request->new($method, $url, $headers, $content);
> -    } else {
> -	$request = HTTP::Request->new($method, $url, $headers);
> -    }
> -    my $res = $self->{ua}->request($request);
> -    if (!$res->is_success) {
> -	# check for nonce rejection
> -	if ($res->code == 400 && $res->decoded_content) {
> -	    my $parsed_content = fromjs($res->decoded_content);
> -	    if ($parsed_content->{type} eq 'urn:ietf:params:acme:error:badNonce') {
> -		warn("bad Nonce, retrying\n");
> -		$self->{nonce} = $res->header('Replay-Nonce');
> -		return $self->do($method, $url, $data, $use_jwk);
> -	    }
> -	}
> -	$self->fatal("Error: $method to $url\n".$res->decoded_content, $res);
> -    }
> -    if (my $nonce = $res->header('Replay-Nonce')) {
> -	$self->{nonce} = $nonce;
> -    }
> -    return $res;
> -}
> -
> -1;
> diff --git a/src/PVE/ACME/Challenge.pm b/src/PVE/ACME/Challenge.pm
> deleted file mode 100644
> index 40d32b6..0000000
> --- a/src/PVE/ACME/Challenge.pm
> +++ /dev/null
> @@ -1,22 +0,0 @@
> -package PVE::ACME::Challenge;
> -
> -use strict;
> -use warnings;
> -
> -sub supported_challenge_types {
> -    return {};
> -}
> -
> -sub setup {
> -    my ($class, $acme, $authorization) = @_;
> -
> -    die "implement me\n";
> -}
> -
> -sub teardown {
> -    my ($self) = @_;
> -
> -    die "implement me\n";
> -}
> -
> -1;
> diff --git a/src/PVE/ACME/StandAlone.pm b/src/PVE/ACME/StandAlone.pm
> deleted file mode 100644
> index f48d638..0000000
> --- a/src/PVE/ACME/StandAlone.pm
> +++ /dev/null
> @@ -1,71 +0,0 @@
> -package PVE::ACME::StandAlone;
> -
> -use strict;
> -use warnings;
> -
> -use HTTP::Daemon;
> -use HTTP::Response;
> -
> -use base qw(PVE::ACME::Challenge);
> -
> -sub supported_challenge_types {
> -    return { 'http-01' => 1 };
> -}
> -
> -sub setup {
> -    my ($class, $acme, $authorization) = @_;
> -
> -    my $challenges = $authorization->{challenges};
> -    die "no challenges defined in authorization\n" if !$challenges;
> -
> -    my $http_challenges = [ grep {$_->{type} eq 'http-01'} @$challenges ];
> -    die "no http-01 challenge defined in authorization\n"
> -	if ! scalar $http_challenges;
> -
> -    my $http_challenge = $http_challenges->[0];
> -
> -    die "no token found in http-01 challenge\n" if !$http_challenge->{token};
> -
> -    my $key_authorization = $acme->key_authorization($http_challenge->{token});
> -
> -    my $server = HTTP::Daemon->new(
> -	LocalPort => 80,
> -	ReuseAddr => 1,
> -    ) or die "Failed to initialize HTTP daemon\n";
> -    my $pid = fork() // die "Failed to fork HTTP daemon - $!\n";
> -    if ($pid) {
> -	my $self = {
> -	    server => $server,
> -	    pid => $pid,
> -	    authorization => $authorization,
> -	    key_auth => $key_authorization,
> -	    url => $http_challenge->{url},
> -	};
> -
> -	return bless $self, $class;
> -    } else {
> -	while (my $c = $server->accept()) {
> -	    while (my $r = $c->get_request()) {
> -		if ($r->method() eq 'GET' and $r->uri->path eq "/.well-known/acme-challenge/$http_challenge->{token}") {
> -		    my $resp = HTTP::Response->new(200, 'OK', undef, $key_authorization);
> -		    $resp->request($r);
> -		    $c->send_response($resp);
> -		} else {
> -		    $c->send_error(404, 'Not found.')
> -		}
> -	    }
> -	    $c->close();
> -	    $c = undef;
> -	}
> -    }
> -}
> -
> -sub teardown {
> -    my ($self) = @_;
> -
> -    eval { $self->{server}->close() };
> -    kill('KILL', $self->{pid});
> -    waitpid($self->{pid}, 0);
> -}
> -
> -1;
> -- 
> 2.20.1
> 
> 
> _______________________________________________
> pve-devel mailing list
> pve-devel at pve.proxmox.com
> https://pve.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
> 
> 




More information about the pve-devel mailing list