diff --git a/docs/source/advanced/hierarchy/define_service_node.rst b/docs/source/advanced/hierarchy/define_service_node.rst index f53430441..5a6027969 100644 --- a/docs/source/advanced/hierarchy/define_service_node.rst +++ b/docs/source/advanced/hierarchy/define_service_node.rst @@ -146,6 +146,11 @@ group **compute2** are serviced by sn2: chdef -t group compute1 servicenode=sn1 xcatmaster=sn1-c chdef -t group compute2 servicenode=sn2 xcatmaster=sn2-c +For delegated certificate requests, the service node's source address when +connecting to ``site.master`` must match one of the compute node's +``servicenode`` entries. Hostname entries are resolved on the management node +before comparison. + .. note:: In this example, sn1 and sn2 are the node names of the service nodes (and therefore the hostnames associated with the NICs that the MN talks to). The hostnames sn1-c and sn2-c are associated with the SN NICs that communicate diff --git a/docs/source/advanced/hierarchy/define_service_nodes.rst b/docs/source/advanced/hierarchy/define_service_nodes.rst index 8b13f99df..405a42e40 100644 --- a/docs/source/advanced/hierarchy/define_service_nodes.rst +++ b/docs/source/advanced/hierarchy/define_service_nodes.rst @@ -76,6 +76,11 @@ The following table illustrates the cluster being used in this example: * ``servicenode`` - defines which Service Node the **Management Node** should send commands to (e.g ``xdsh``) and should be set to the hostname or IP address of the service node that the management node can contact it by. * ``xcatmaster`` - defines which Service Node the **Compute Node** should boot from and should be set to the hostname or IP address of the service node that the compute node can contact it by. + For delegated certificate requests, the service node's source address when + connecting to ``site.master`` must match one of the compute node's + ``servicenode`` entries. Hostname entries are resolved on the management + node before comparison. + You must set both ``servicenode`` and ``xcatmaster`` regardless of whether or not you are using service node pools. For most scenarios, the value will be identical. :: chdef -t group -o rack1 servicenode=r1n01 xcatmaster=r1n01 diff --git a/xCAT-server/lib/xcat/plugins/credentials.pm b/xCAT-server/lib/xcat/plugins/credentials.pm index 500388209..4f5901771 100644 --- a/xCAT-server/lib/xcat/plugins/credentials.pm +++ b/xCAT-server/lib/xcat/plugins/credentials.pm @@ -19,6 +19,7 @@ Supported command: getcredentials + signx509cert (service-node delegation only) =cut @@ -29,15 +30,20 @@ use xCAT::Table; use Data::Dumper; use xCAT::NodeRange; use xCAT::Zone; +use File::Temp qw(tempfile); use IO::Socket::INET; use Time::HiRes qw(sleep); use xCAT::Utils; +use xCAT::NetworkUtils; use xCAT::PasswordUtils; +use xCAT::TableUtils; use xCAT::MsgUtils; use Getopt::Long; +use constant DELEGATED_SIGNING_TIMEOUT => 30; + #------------------------------------------------------- =head3 handled_commands @@ -50,7 +56,10 @@ Return list of commands handled by this plugin sub handled_commands { - return { getcredentials => "credentials" }; + return { + getcredentials => "credentials", + signx509cert => "credentials", + }; } #------------------------------------------------------- @@ -74,6 +83,30 @@ sub process_request my $envs = $request->{env}; my $client; + if ($command eq 'signx509cert') { + my $node = $request->{arg} ? $request->{arg}->[0] : undef; + my $csr = $request->{csr} ? $request->{csr}->[0] : undef; + unless ($node and $csr and _delegated_signer_allowed($request, $node)) { + xCAT::MsgUtils->trace(0, 'E', 'Rejected delegated certificate request'); + $callback->({ error => ['Delegated certificate request denied'], errorcode => [1] }); + return; + } + my $certificate = _sign_x509_certificate($node, $csr); + unless ($certificate) { + $callback->({ error => ["Unable to sign certificate for $node"], errorcode => [1] }); + return; + } + my $peer = $request->{'_xcat_clientip'} + ? $request->{'_xcat_clientip'}->[0] + : $request->{'_xcat_clienthost'} + ? $request->{'_xcat_clienthost'}->[0] + : $request->{'_xcat_clientfqdn'}->[0]; + xCAT::MsgUtils->trace(0, 'I', + "credentials: signed certificate for $node requested by $peer"); + $callback->({ data => [{ content => [$certificate], desc => ['x509cert'] }] }); + return; + } + #Because clients may be stuck with stunnel, we cannot presume they #can explicitly bind to a low port number as a client #unless ($request and $request->{'_xcat_clientport'} and $request->{'_xcat_clientport'}->[0] and $request->{'_xcat_clientport'}->[0] < 1000) { @@ -281,60 +314,18 @@ sub process_request } elsif ($parm =~ /x509cert/) { xCAT::MsgUtils->trace(0, 'I', "credentials: sending $parm to $client"); my $csr = $request->{'csr'}->[0]; - my $csrfile; - my $oldumask = umask 0077; - if (-e "/tmp/xcat/client.csr.$$") { unlink "/tmp/xcat/client.csr.$$"; } - open($csrfile, ">", "/tmp/xcat/client.csr.$$"); - unless ($csrfile) { next; } - my @statdat = stat $csrfile; - - while ($statdat[4] != 0 or $statdat[2] & 020 or $statdat[2] & 002) { #try to be paranoid, root better own the file, and it better not be writable by anyone but owner - #this means to assure the filehandle is not write-accessible to others who may insert their malicious CSR - close($csrfile); - unlink("/tmp/xcat/client.csr.$$"); - open($csrfile, ">", "/tmp/xcat/client.csr.$$"); - @statdat = stat $csrfile; + my ($certificate, $error); + if (xCAT::Utils->isServiceNode()) { + ($certificate, $error) = _request_x509_from_master($client, $csr); + } else { + $certificate = _sign_x509_certificate($client, $csr); } - print $csrfile $csr; - close($csrfile); - - #ok, at this point, we can verify that the subject is one we wouldn't mind signing... - my $subject = `openssl req -in /tmp/xcat/client.csr.$$ -subject -noout`; - chomp($subject); - unless ($subject =~ /CN=$client\z/) { unlink("/tmp/xcat/client.csr.$$"); next; } - unlink "/tmp/xcat/client.cert.$$"; - open($csrfile, ">", "/tmp/xcat/client.cert.$$"); - @statdat = stat $csrfile; - while ($statdat[4] != 0 or $statdat[2] & 020 or $statdat[2] & 002) { #try to be paranoid, root better own the file, and it better not be writable by anyone but owner - #this prevents an attacker from predicting pid and pre-setting up a file that they can corrupt for DoS - close($csrfile); - unlink("/tmp/xcat/client.csr.$$"); - open($csrfile, ">", "/tmp/xcat/client.csr.$$"); - @statdat = stat $csrfile; + if ($certificate) { + push @{ $rsp->{'data'} }, { content => [$certificate], desc => [$parm] }; + } elsif ($error) { + push @{ $rsp->{'error'} }, $error; } - close($csrfile); - open($csrfile, "<", "/etc/xcat/ca/index"); - my @caindex = <$csrfile>; - close($csrfile); - foreach (@caindex) { - chomp; - my ($type, $expiry, $revoke, $serial, $fname, $subject) = split /\t/; - if ($type eq 'V' and $subject =~ /CN=$client\z/) { #we already have a valid certificate, new request replaces it, revoke old - #print "The time of replacing is at hand for $client\n"; - xCAT::MsgUtils->trace(0, 'I', "credentials: The time of replacing is at hand for $client"); - system("openssl ca -config /etc/xcat/ca/openssl.cnf -revoke /etc/xcat/ca/certs/$serial.pem"); - } - } - my $rc = system("openssl ca -config /etc/xcat/ca/openssl.cnf -in /tmp/xcat/client.csr.$$ -out /tmp/xcat/client.cert.$$ -batch"); - unlink("/tmp/xcat/client.csr.$$"); - umask($oldumask); - if ($rc) { next; } - open($csrfile, "<", "/tmp/xcat/client.cert.$$"); - my @certdata = <$csrfile>; - close($csrfile); - unlink "/tmp/xcat/client.cert.$$"; - my $certcontents = join('', @certdata); - push @{ $rsp->{'data'} }, { content => [$certcontents], desc => [$parm] }; + next; } elsif ($parm =~ /xcat_secure_pw:/) { xCAT::MsgUtils->trace(0, 'I', "credentials: sending $parm to $client"); my @users=split(/:/,$parm); @@ -376,6 +367,189 @@ sub process_request return; } +sub _delegated_signer_allowed { + my ($request, $node) = @_; + return 0 unless $request->{'_xcat_authname'} + and $request->{'_xcat_authname'}->[0] eq 'root'; + return 0 unless $node; + + my %requester; + my @identity_attributes = $request->{'_xcat_clientip'} + ? qw(_xcat_clientip) + : qw(_xcat_clienthost _xcat_clientfqdn); + foreach my $attribute (@identity_attributes) { + next unless $request->{$attribute}; + my $value = ref($request->{$attribute}) eq 'ARRAY' + ? $request->{$attribute}->[0] : $request->{$attribute}; + $requester{$_} = 1 for _endpoint_identities($value); + } + return 0 unless %requester; + + my $noderes = xCAT::Table->new('noderes'); + return 0 unless $noderes; + my $attributes = $noderes->getNodeAttribs($node, ['servicenode']); + return 0 unless $attributes and $attributes->{servicenode}; + + foreach my $service_node (split /,/, $attributes->{servicenode}) { + return 1 if grep { $requester{$_} } _endpoint_identities($service_node); + } + return 0; +} + +sub _endpoint_identities { + my $endpoint = shift; + return unless defined($endpoint); + $endpoint =~ s/^\s+|\s+$//g; + return unless length($endpoint); + + my %identities; + my $name = _normalize_endpoint_identity($endpoint); + $identities{$name} = 1; + + my @addresses = xCAT::NetworkUtils->getipaddr($endpoint, GetAllAddresses => 1); + foreach my $address (@addresses) { + next unless defined($address) && length($address); + $identities{_normalize_endpoint_identity($address)} = 1; + } + return keys %identities; +} + +sub _normalize_endpoint_identity { + my $identity = lc(shift); + $identity =~ s/\.$//; + $identity =~ s/^::ffff:(?=\d+(?:\.\d+){3}\z)//; + return $identity; +} + +sub _request_x509_from_master { + my ($node, $csr) = @_; + my @masters = xCAT::TableUtils->get_site_attribute('master'); + unless ($masters[0]) { + my $error = 'The management node is not configured'; + xCAT::MsgUtils->trace(0, 'E', $error); + return wantarray ? (undef, $error) : undef; + } + + require xCAT::Client; + local $ENV{XCATHOST} = $masters[0] =~ /:/ + ? "[$masters[0]]:3001" + : "$masters[0]:3001"; + my $certificate; + my @errors; + my $request = { + command => ['signx509cert'], + arg => [$node], + csr => [$csr], + }; + my $failure; + { + local $SIG{ALRM} = sub { + die 'management node request timed out after ' + . DELEGATED_SIGNING_TIMEOUT . " seconds\n"; + }; + my $request_ok = eval { + alarm(DELEGATED_SIGNING_TIMEOUT); + xCAT::Client::submit_request($request, sub { + my $response = shift; + my $response_errors = $response->{error}; + if (defined($response_errors)) { + my @response_errors = ref($response_errors) eq 'ARRAY' + ? @{$response_errors} : ($response_errors); + push @errors, + grep { defined($_) && !ref($_) && length($_) } @response_errors; + } + foreach my $data (@{ $response->{data} || [] }) { + next unless ref($data) eq 'HASH'; + my $description = ref($data->{desc}) eq 'ARRAY' + ? $data->{desc}->[0] : $data->{desc}; + next unless $description and $description eq 'x509cert'; + $certificate = ref($data->{content}) eq 'ARRAY' + ? $data->{content}->[0] : $data->{content}; + } + }); + 1; + }; + $failure = $@ unless $request_ok; + alarm(0); + } + unless ($certificate) { + my $reason = $failure || join('; ', @errors) || 'management node returned no certificate'; + $reason =~ s/[\r\n]+/ /g; + $reason =~ s/\s+$//; + my $error = "Unable to obtain a delegated certificate for $node: $reason"; + xCAT::MsgUtils->trace(0, 'E', $error); + return wantarray ? (undef, $error) : undef; + } + return wantarray ? ($certificate, undef) : $certificate; +} + +sub _csr_subject_matches_node { + my ($csrpath, $client) = @_; + open(my $subject_file, '-|', 'openssl', 'req', '-in', $csrpath, + '-subject', '-noout', '-nameopt', 'RFC2253') + or die "cannot inspect CSR"; + my $subject = <$subject_file>; + close($subject_file) or die "invalid CSR"; + $subject =~ s/[\r\n]+$// if defined($subject); + return 0 unless defined($subject); + return $subject =~ /^subject=\s*CN=\Q$client\E\z/ ? 1 : 0; +} + +sub _sign_x509_certificate { + my ($client, $csr) = @_; + my $oldumask = umask 0077; + my ($csrfile, $csrpath); + my ($certfile, $certpath); + my $certificate; + my $failure; + + my $signing_ok = eval { + ($csrfile, $csrpath) = tempfile('xcat-client-csr-XXXXXX', TMPDIR => 1, UNLINK => 0); + print {$csrfile} $csr or die "cannot write CSR"; + close($csrfile) or die "cannot close CSR"; + + die "certificate subject does not match node" + unless _csr_subject_matches_node($csrpath, $client); + + ($certfile, $certpath) = tempfile('xcat-client-cert-XXXXXX', TMPDIR => 1, UNLINK => 0); + close($certfile) or die "cannot close certificate file"; + + open(my $index, '<', '/etc/xcat/ca/index') or die "cannot read CA index"; + my @caindex = <$index>; + close($index) or die "cannot close CA index"; + foreach (@caindex) { + chomp; + my ($type, $expiry, $revoke, $serial, $fname, $certificate_subject) = split /\t/; + if ($type eq 'V' and $certificate_subject =~ /^\/CN=\Q$client\E\z/) { + xCAT::MsgUtils->trace(0, 'I', "credentials: replacing the certificate for $client"); + system('openssl', 'ca', '-config', '/etc/xcat/ca/openssl.cnf', + '-revoke', "/etc/xcat/ca/certs/$serial.pem") == 0 + or die "cannot revoke previous certificate"; + } + } + system('openssl', 'ca', '-config', '/etc/xcat/ca/openssl.cnf', + '-in', $csrpath, '-out', $certpath, '-batch') == 0 + or die "certificate signing failed"; + + open(my $signed, '<', $certpath) or die "cannot read signed certificate"; + local $/; + $certificate = <$signed>; + close($signed) or die "cannot close signed certificate"; + 1; + }; + $failure = $@ unless $signing_ok; + + unlink($csrpath) if $csrpath and -e $csrpath; + unlink($certpath) if $certpath and -e $certpath; + umask($oldumask); + if ($failure) { + chomp($failure); + xCAT::MsgUtils->trace(0, 'E', "Unable to sign certificate for $client: $failure"); + return; + } + return $certificate; +} + sub ok_with_node { my $node = shift; diff --git a/xCAT-server/sbin/xcatd b/xCAT-server/sbin/xcatd index 42c690a9b..29fe6f581 100755 --- a/xCAT-server/sbin/xcatd +++ b/xCAT-server/sbin/xcatd @@ -1587,6 +1587,7 @@ until ($quit) { $clientselect->add($connection); my $peerhost = undef; my $peerfqdn = undef; + my $peerip = $connection->peerhost(); my $peer = $connection->peer_certificate("owner"); if ($peer) { $peer =~ m/CN=([^\/]*)/; @@ -1629,11 +1630,12 @@ until ($quit) { #printf('info'.": xcatd: connection from ".($peername ? $peername . "@" . $peerhost : $peerhost)."\n"); - my $debugmsg = "xcatd: connection from " . ($peername ? $peername . "@" . $peerhost : $peerhost) . "\n"; + my $peerdisplay = $peerhost || $peerip; + my $debugmsg = "xcatd: connection from " . ($peername ? $peername . "@" . $peerdisplay : $peerdisplay) . "\n"; xCAT::MsgUtils->trace(0, "D", "$debugmsg"); - $$progname = "xcatd SSL: Instance for " . ($peername ? $peername . "@" . $peerhost : $peerhost) if $peerhost; - service_connection($connection, $peername, $peerhost, $peerfqdn, $peerhostorg); + $$progname = "xcatd SSL: Instance for " . ($peername ? $peername . "@" . $peerdisplay : $peerdisplay) if $peerdisplay; + service_connection($connection, $peername, $peerhost, $peerfqdn, $peerhostorg, $peerip); xexit(0); } if ($sslfudgefactor) { $sslfudgefactor -= 1; } @@ -2808,6 +2810,7 @@ sub service_connection { my $peerhost = shift; my $peerfqdn = shift; my $peerhostorg = shift; + my $peerip = shift; my $peerport = $sock->peerport; # some paranoid measures could reduce a third party abusing stage3 image to attempting to get USER/PASS for BMCs: @@ -2969,6 +2972,9 @@ sub service_connection { $req->{'_xcat_authname'} = [$peername]; $req->{'_xcat_clienthost'} = [$peerhost]; $req->{'_xcat_clientfqdn'} = [$peerfqdn]; + if ($req->{command}->[0] eq 'signx509cert') { + $req->{'_xcat_clientip'} = [$peerip]; + } $req->{'_xcat_clientport'} = [$peerport]; $$progname = "xcatd SSL: " . $req->{command}->[0]; if ($req->{noderange} && defined($req->{noderange}->[0])) { diff --git a/xCAT-test/unit/credentials_service_node.t b/xCAT-test/unit/credentials_service_node.t new file mode 100644 index 000000000..b1cfed285 --- /dev/null +++ b/xCAT-test/unit/credentials_service_node.t @@ -0,0 +1,493 @@ +#!/usr/bin/env perl +## no critic (TestingAndDebugging::ProhibitNoStrict) +use strict; +use warnings; + +use File::Spec; +use File::Temp qw(tempdir); +use FindBin; +use Test::More; + +BEGIN { + package xCAT::Table; + our $servicenode; + sub import { } + sub new { return bless {}, shift; } + sub getNodeAttribs { return { servicenode => $servicenode }; } + $INC{'xCAT/Table.pm'} = 1; + + package xCAT::NodeRange; + our %ranges; + sub import { + no strict 'refs'; + *{ caller() . '::noderange' } = \&noderange; + } + sub noderange { + my ($name) = @_; + return @{ $ranges{$name} || [] }; + } + $INC{'xCAT/NodeRange.pm'} = 1; + + package xCAT::Zone; + sub import { } + $INC{'xCAT/Zone.pm'} = 1; + + package xCAT::Utils; + our $service_node; + sub import { } + sub isAIX { return 0; } + sub isServiceNode { return $service_node; } + $INC{'xCAT/Utils.pm'} = 1; + + package xCAT::NetworkUtils; + our %addresses; + sub import { } + sub getipaddr { + my ($class, $endpoint) = @_; + return @{ $addresses{$endpoint} || [] }; + } + $INC{'xCAT/NetworkUtils.pm'} = 1; + + package xCAT::PasswordUtils; + sub import { } + $INC{'xCAT/PasswordUtils.pm'} = 1; + + package xCAT::TableUtils; + our $master = '192.0.2.10'; + sub import { } + sub get_site_attribute { return ($master); } + $INC{'xCAT/TableUtils.pm'} = 1; + + package xCAT::MsgUtils; + our @messages; + sub import { } + sub trace { push @messages, [@_]; } + sub message { + push @messages, [@_]; + my $callback = $_[3]; + $callback->($_[2]) if ref($callback) eq 'CODE'; + } + $INC{'xCAT/MsgUtils.pm'} = 1; + + package xCAT::Client; + our @responses; + our ($exception, $request, $host, $trigger_timeout); + sub import { } + sub submit_request { + ($request, my $callback) = @_; + $host = $ENV{XCATHOST}; + die $exception if defined($exception); + $SIG{ALRM}->() if $trigger_timeout; + $callback->($_) for @responses; + } + $INC{'xCAT/Client.pm'} = 1; + + package LWP; + sub import { } + $INC{'LWP.pm'} = 1; + + package LWP::UserAgent; + sub new { return bless {}, shift; } + + package HTTP::Request::Common; + sub import { + no strict 'refs'; + *{ caller() . '::GET' } = sub { return $_[0]; }; + } + $INC{'HTTP/Request/Common.pm'} = 1; +} + +my $repo_root = File::Spec->catdir( $FindBin::Bin, '..', '..' ); +my $plugin = File::Spec->catfile( + $repo_root, qw(xCAT-server lib xcat plugins credentials.pm) +); +require $plugin; + +sub reset_client { + @xCAT::Client::responses = (); + @xCAT::MsgUtils::messages = (); + $xCAT::Client::exception = undef; + $xCAT::Client::request = undef; + $xCAT::Client::host = undef; + $xCAT::Client::trigger_timeout = 0; + $xCAT::TableUtils::master = '192.0.2.10'; +} + +my $commands = xCAT_plugin::credentials::handled_commands(); +is( $commands->{getcredentials}, 'credentials', + 'credentials plugin still handles client requests' ); +is( $commands->{signx509cert}, 'credentials', + 'credentials plugin handles delegated signing' ); + +$xCAT::Table::servicenode = 'service-a, service-b.example.test'; +%xCAT::NetworkUtils::addresses = ( + '192.0.2.21' => ['192.0.2.21'], + '192.0.2.22' => ['192.0.2.22'], + '192.0.2.23' => ['192.0.2.23'], + '::ffff:192.0.2.22' => ['::ffff:192.0.2.22'], + 'service-a' => ['192.0.2.21'], + 'service-b' => ['192.0.2.22'], + 'service-b-alias' => ['192.0.2.22'], + 'service-b.example.test' => ['192.0.2.22'], + 'service-c' => ['192.0.2.23'], + 'service-c.example.test' => ['192.0.2.23'], +); + +ok( + xCAT_plugin::credentials::_delegated_signer_allowed( + { + _xcat_authname => ['root'], + _xcat_clienthost => ['service-a'], + _xcat_clientip => ['192.0.2.21'], + }, + 'compute-01' + ), + 'assigned authenticated service node may request a certificate' +); +ok( + xCAT_plugin::credentials::_delegated_signer_allowed( + { + _xcat_authname => ['root'], + _xcat_clientfqdn => ['service-b.example.test'], + _xcat_clientip => ['192.0.2.22'], + }, + 'compute-01' + ), + 'assigned service node may match its FQDN' +); +ok( + xCAT_plugin::credentials::_delegated_signer_allowed( + { + _xcat_authname => ['root'], + _xcat_clientip => ['192.0.2.22'], + }, + 'compute-01' + ), + 'numeric peer address works without reverse DNS' +); +ok( + xCAT_plugin::credentials::_delegated_signer_allowed( + { + _xcat_authname => ['root'], + _xcat_clientip => ['::ffff:192.0.2.22'], + }, + 'compute-01' + ), + 'IPv4-mapped peer address matches the assigned IPv4 service node' +); + +$xCAT::Table::servicenode = '192.0.2.22'; +ok( + xCAT_plugin::credentials::_delegated_signer_allowed( + { + _xcat_authname => ['root'], + _xcat_clienthost => ['service-b'], + _xcat_clientip => ['192.0.2.22'], + }, + 'compute-01' + ), + 'service node assignment may use an IP address' +); + +$xCAT::Table::servicenode = 'service-b-alias'; +ok( + xCAT_plugin::credentials::_delegated_signer_allowed( + { + _xcat_authname => ['root'], + _xcat_clientip => ['192.0.2.22'], + }, + 'compute-01' + ), + 'service node assignment may use a resolvable alias' +); + +$xCAT::Table::servicenode = 'service-b.example.test'; +ok( + !xCAT_plugin::credentials::_delegated_signer_allowed( + { + _xcat_authname => ['root'], + _xcat_clienthost => ['service-b'], + _xcat_clientip => ['192.0.2.23'], + }, + 'compute-01' + ), + 'reverse DNS name cannot override a different peer address' +); + +ok( + !xCAT_plugin::credentials::_delegated_signer_allowed( + { + _xcat_authname => ['root'], + _xcat_clienthost => ['service-c'], + _xcat_clientfqdn => ['service-c.example.test'], + _xcat_clientip => ['192.0.2.23'], + }, + 'compute-01' + ), + 'resolved but unassigned service node is rejected' +); +ok( + !xCAT_plugin::credentials::_delegated_signer_allowed( + { + _xcat_clienthost => ['service-b'], + _xcat_clientip => ['192.0.2.22'], + }, + 'compute-01' + ), + 'unauthenticated request is rejected' +); +ok( + !xCAT_plugin::credentials::_delegated_signer_allowed( + { _xcat_authname => ['root'] }, 'compute-01' + ), + 'authenticated request without a peer identity is rejected' +); + +reset_client(); +@xCAT::Client::responses = ( + { + data => [ + { + desc => ['x509cert'], + content => 'scalar certificate', + } + ] + }, + { serverdone => [undef] }, +); +my ($certificate, $error) = + xCAT_plugin::credentials::_request_x509_from_master( + 'compute-01', 'certificate request' + ); +is( $certificate, 'scalar certificate', + 'delegated signer accepts the serialized xCAT response shape' ); +is( $error, undef, 'successful delegated request has no error' ); +is( $xCAT::Client::host, '192.0.2.10:3001', + 'delegated request targets site.master' ); +is_deeply( + $xCAT::Client::request, + { + command => ['signx509cert'], + arg => ['compute-01'], + csr => ['certificate request'], + }, + 'delegated request carries the node and CSR' +); + +reset_client(); +$xCAT::TableUtils::master = '2001:db8::10'; +@xCAT::Client::responses = ( + { + data => [ + { + desc => 'x509cert', + content => ['array certificate'], + } + ] + }, +); +($certificate, $error) = xCAT_plugin::credentials::_request_x509_from_master( + 'compute-01', 'certificate request' +); +is( $certificate, 'array certificate', + 'delegated signer tolerates native array values' ); +is( $xCAT::Client::host, '[2001:db8::10]:3001', + 'IPv6 management address is bracketed' ); + +reset_client(); +@xCAT::Client::responses = ( + { error => ['Delegated certificate request denied'], errorcode => [1] } +); +($certificate, $error) = xCAT_plugin::credentials::_request_x509_from_master( + 'compute-01', 'certificate request' +); +is( $certificate, undef, 'management node rejection returns no certificate' ); +like( $error, qr/Delegated certificate request denied\z/, + 'management node error is returned to the service node' ); +like( $xCAT::MsgUtils::messages[-1]->[3], + qr/Delegated certificate request denied\z/, + 'management node error is logged on the service node' ); + +reset_client(); +$xCAT::Client::exception = "TLS handshake failed\n"; +($certificate, $error) = xCAT_plugin::credentials::_request_x509_from_master( + 'compute-01', 'certificate request' +); +like( $error, qr/TLS handshake failed\z/, + 'client exception is preserved in the delegated error' ); +like( $xCAT::MsgUtils::messages[-1]->[3], qr/TLS handshake failed\z/, + 'client exception is logged on the service node' ); + +reset_client(); +$xCAT::Client::trigger_timeout = 1; +($certificate, $error) = xCAT_plugin::credentials::_request_x509_from_master( + 'compute-01', 'certificate request' +); +like( $error, qr/request timed out after 30 seconds\z/, + 'stalled management node request is bounded by a timeout' ); + +reset_client(); +($certificate, $error) = xCAT_plugin::credentials::_request_x509_from_master( + 'compute-01', 'certificate request' +); +like( $error, qr/management node returned no certificate\z/, + 'empty management node response has a specific error' ); + +reset_client(); +$xCAT::TableUtils::master = undef; +($certificate, $error) = xCAT_plugin::credentials::_request_x509_from_master( + 'compute-01', 'certificate request' +); +is( $error, 'The management node is not configured', + 'missing site.master has a specific error' ); + +$xCAT::Table::servicenode = 'service-a'; +@xCAT::MsgUtils::messages = (); +my @callback; +{ + no warnings 'redefine'; + local *xCAT_plugin::credentials::_sign_x509_certificate = sub { + return 'signed certificate'; + }; + xCAT_plugin::credentials::process_request( + { + command => ['signx509cert'], + arg => ['compute-01'], + csr => ['certificate request'], + _xcat_authname => ['root'], + _xcat_clienthost => ['service-a'], + _xcat_clientip => ['192.0.2.21'], + }, + sub { push @callback, shift; } + ); +} +is_deeply( + \@callback, + [ + { + data => [ + { + content => ['signed certificate'], + desc => ['x509cert'], + } + ] + } + ], + 'authorized delegated request returns only the signed certificate' +); +is_deeply( + $xCAT::MsgUtils::messages[-1], + [ + 'xCAT::MsgUtils', + 0, + 'I', + 'credentials: signed certificate for compute-01 requested by 192.0.2.21', + ], + 'successful delegated signing records the node and service node address' +); + +@callback = (); +xCAT_plugin::credentials::process_request( + { + command => ['signx509cert'], + arg => ['compute-01'], + csr => ['certificate request'], + _xcat_authname => ['root'], + _xcat_clienthost => ['service-c'], + _xcat_clientip => ['192.0.2.23'], + }, + sub { push @callback, shift; } +); +is( $callback[0]->{errorcode}->[0], 1, + 'unauthorized delegated request returns an error' ); + +%xCAT::NodeRange::ranges = ( 'compute-01' => ['compute-01'] ); +$xCAT::Utils::service_node = 1; +@callback = (); +{ + no warnings 'redefine'; + local *xCAT_plugin::credentials::ok_with_node = sub { return 1; }; + local *xCAT_plugin::credentials::_request_x509_from_master = sub { + return ('delegated certificate', undef); + }; + xCAT_plugin::credentials::process_request( + { + command => ['getcredentials'], + arg => ['x509cert'], + csr => ['certificate request'], + callback_port => [123], + _xcat_clienthost => ['compute-01'], + }, + sub { push @callback, shift; } + ); +} +is( $callback[0]->{data}->[0]->{content}->[0], 'delegated certificate', + 'service-node getcredentials path returns the delegated certificate' ); + +@callback = (); +{ + no warnings 'redefine'; + local *xCAT_plugin::credentials::ok_with_node = sub { return 1; }; + local *xCAT_plugin::credentials::_request_x509_from_master = sub { + return (undef, 'management node rejected the request'); + }; + xCAT_plugin::credentials::process_request( + { + command => ['getcredentials'], + arg => ['x509cert'], + csr => ['certificate request'], + callback_port => [123], + _xcat_clienthost => ['compute-01'], + }, + sub { push @callback, shift; } + ); +} +is( $callback[0]->{error}->[0], 'management node rejected the request', + 'service-node getcredentials path returns the delegated error' ); + +SKIP: { + my $openssl = 0; + if (open(my $version, '-|', 'openssl', 'version')) { + <$version>; + $openssl = close($version); + } + skip 'openssl is not available', 3 unless $openssl; + + my $directory = tempdir(CLEANUP => 1); + my $key = File::Spec->catfile($directory, 'test.key'); + open(my $saved_stderr, '>&', \*STDERR) or die "cannot save stderr"; + open(STDERR, '>', File::Spec->devnull()) or die "cannot redirect stderr"; + my $key_status = system('openssl', 'genrsa', '-out', $key, '2048'); + open(STDERR, '>&', $saved_stderr) or die "cannot restore stderr"; + close($saved_stderr); + $key_status == 0 or die "cannot create test key"; + my @subjects = ( + [ valid => '/CN=compute-01' ], + [ multiple => '/CN=root/CN=compute-01' ], + [ extra => '/CN=compute-01/OU=cluster' ], + ); + my %requests; + foreach my $subject (@subjects) { + my ($name, $distinguished_name) = @{$subject}; + my $request = File::Spec->catfile($directory, "$name.csr"); + system('openssl', 'req', '-new', '-key', $key, + '-subj', $distinguished_name, '-out', $request) == 0 + or die "cannot create test CSR"; + $requests{$name} = $request; + } + + ok( xCAT_plugin::credentials::_csr_subject_matches_node( + $requests{valid}, 'compute-01' + ), + 'single exact CN is accepted' ); + ok( !xCAT_plugin::credentials::_csr_subject_matches_node( + $requests{multiple}, 'compute-01' + ), + 'multiple CN values are rejected' ); + ok( !xCAT_plugin::credentials::_csr_subject_matches_node( + $requests{extra}, 'compute-01' + ), + 'additional subject attributes are rejected' ); +} + +done_testing();