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.l...@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:exam...@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@pve.proxmox.com > https://pve.proxmox.com/cgi-bin/mailman/listinfo/pve-devel > > _______________________________________________ pve-devel mailing list pve-devel@pve.proxmox.com https://pve.proxmox.com/cgi-bin/mailman/listinfo/pve-devel