1 2=head1 OpenXPKI::Client::Simple 3 4An easy to use class to connect to the openxpki daemon and run commands 5Designed as a kind of CLI interface for inline use within scripts. By 6default, it will not handle sessions and create a new session using the 7given auth info on each new instance (subsequent commands within one call 8are run on the same session). If you pass (and maintain) a session object to 9the constructor, it is used to persist the backend session during requests. 10 11=head2 Construction 12 13The client is constructed calling the new method, the required configuration 14can be set via one of three options: 15 16=head3 Explicit Config 17 18Pass the configuration as hash to the new method, must set at least 19I<config.socket> and I<config.realm> (omit if server has only one realm). 20 21The default authentication is anonymous but can be overidden by setting 22I<auth.stack> and appropriate keys for the chosen login method. 23 24An instance of Log4perl can be passed via I<logger>, default is to log to 25STDERR with loglevel error. 26 27=head3 Explicit Config from File 28 29Pass the name of the config file to use as string to the new method, the 30file must be in the standard config ini format and have at least a section 31I<global> providing I<socket> and I<realm>. 32 33If an I<auth> section exists, it is mapped as is to the I<auth> parameter. 34 35You can set a loglevel and logfile location using I<log.file> and 36I<log.level>. Loglevel must be a Log4perl Level name without the leading 37dollar sign (e.g. level=DEBUG). 38 39=head3 Implicit Config from File 40 41If you do not pass a I<config> argument to the new method, the class tries 42to find a config file at 43 44=over 45 46=item string set in the environment OPENXPKI_CLIENT_CONF 47 48=item $HOME/.openxpki.conf 49 50=item /usr/local/etc/openxpki/client.conf 51 52=back 53 54The same rules as above apply, in case you pass auth or logger as explicit 55arguments the settings in the file are ignored. 56 57=cut 58 59package OpenXPKI::Client::Simple; 60 61use strict; 62use warnings; 63use English; 64use POSIX qw( strftime ); 65use Getopt::Long; 66use Pod::Usage; 67use Data::Dumper; 68use Config::Std; 69use File::Spec; 70use OpenXPKI::Client; 71use OpenXPKI::Serialization::Simple; 72use Log::Log4perl qw(:easy :levels); 73use Log::Log4perl::Level; 74 75 76use Moose; 77use Data::Dumper; 78 79has auth => ( 80 is => 'rw', 81 isa => 'HashRef', 82 lazy => 1, 83 default => sub { return { stack => 'Anonymous', user => undef, pass => undef } } 84); 85 86# ref to the cgi frontend session 87# if undef we behave as "one shot" client 88has 'session' => ( 89 is => 'rw', 90 isa => 'Object|Undef', 91 default => undef, 92 lazy => 1, 93); 94 95has '_config' => ( 96 is => 'ro', 97 isa => 'HashRef', 98 init_arg => 'config', 99 required => 1, 100); 101 102 103has 'realm' => ( 104 is => 'ro', 105 isa => 'Str', 106 lazy => 1, 107 default => sub { my $self = shift; return $self->_config()->{'realm'} } 108); 109 110has 'socket' => ( 111 is => 'ro', 112 isa => 'Str', 113 lazy => 1, 114 default => sub { my $self = shift; return $self->_config()->{'socket'} } 115); 116 117has client => ( 118 is => 'rw', 119 isa => 'Object|Undef', 120 builder => '_build_client', 121 lazy => 1, 122 clearer => '_clear_client', 123); 124 125has logger => ( 126 is => 'rw', 127 isa => 'Object', 128 builder => '_build_logger', 129 init_arg => 'logger', 130 lazy => 1, 131); 132 133has last_reply => ( 134 is => 'rw', 135 isa => 'HashRef|Undef', 136 default => undef, 137); 138 139has last_error => ( 140 is => 'rw', 141 isa => 'Str|Undef', 142 default => undef, 143); 144 145sub _build_logger { 146 if(!Log::Log4perl->initialized()) { 147 Log::Log4perl->easy_init($ERROR); 148 } 149 return Log::Log4perl->get_logger(); 150}; 151 152 153around BUILDARGS => sub { 154 155 my $orig = shift; 156 my $class = shift; 157 my $args = shift; 158 159 # Called with a scalar = use as config file name 160 my $file; 161 if ($args && !ref $args) { 162 die "Given config file does not exist or is not readable!" unless (-e $args && -r $args); 163 $file = $args; 164 $args = {}; 165 166 } elsif (!$args || !$args->{config}) { 167 $file = '/usr/local/etc/openxpki/client.conf'; 168 if ($ENV{OPENXPKI_CLIENT_CONF}) { 169 $file = $ENV{OPENXPKI_CLIENT_CONF}; 170 die "OPENXPKI_CLIENT_CONF is set but files does not exist or is not readable!" unless (-e $file && -r $file); 171 172 } elsif ($ENV{HOME} && -d $ENV{HOME} && -r $ENV{HOME}) { 173 174 my $path = File::Spec->canonpath( $ENV{HOME} ); 175 my $cand = File::Spec->catdir( ( $path, '.openxpki.conf' ) ); 176 $file = $cand if (-e $cand && -r $cand); 177 178 } 179 180 if (!-r $file ) { 181 OpenXPKI::Client::Simple::_build_logger()->fatal("Unable to open configuration file $file"); 182 die "Unable to open configuration file $file"; 183 } 184 } 185 186 if ($file) { 187 my $conf; 188 if (!read_config( $file => $conf )) { 189 OpenXPKI::Client::Simple::_build_logger()->fatal("Unable to read configuration file $file"); 190 die "Unable to read configuration file $file"; 191 } 192 193 $args->{config} = $conf->{global}; 194 195 if ($conf->{auth} && !$args->{auth}) { 196 $args->{auth} = $conf->{auth}; 197 } 198 199 if ($conf->{log} && !$args->{logger}) { 200 my $level = Log::Log4perl::Level::to_priority( uc( $conf->{log}->{level} || 'ERROR' )); 201 if ($conf->{log}->{file}) { 202 Log::Log4perl->easy_init( { level => $level, 203 file => ">>" . $conf->{log}->{file} } ); 204 } else { 205 Log::Log4perl->easy_init($level); 206 } 207 $args->{logger} = Log::Log4perl->get_logger(); 208 } 209 210 if ($args->{logger}) { 211 $args->{logger}->trace('Config read from file ' . $file); 212 } 213 214 return $class->$orig($args); 215 } else { 216 217 return $class->$orig($args); 218 } 219 220}; 221 222sub _build_client { 223 224 my $self = shift; 225 226 my $client = OpenXPKI::Client->new({ 227 SOCKETFILE => $self->socket(), 228 }); 229 230 if (! defined $client) { 231 die "Could not instantiate OpenXPKI client. Stopped"; 232 } 233 234 my $log = $self->logger(); 235 236 $log->debug("Initialize client"); 237 238 my $reply; 239 # if we have a frontend session object, we also create a backend session 240 if ($self->session()) { 241 $reply = $self->__reinit_session( $client ); 242 243 # Init a fresh backend session 244 } else { 245 246 $reply = $client->init_session(); 247 if (!$reply) { 248 die "Could not initiate OpenXPKI server session. Stopped"; 249 } 250 $log->debug("Started volatile session with id: " . $client->get_session_id() ); 251 } 252 253 # this should not happen 254 $reply = $client->send_receive_service_msg('PING') unless($reply); 255 $self->last_reply( $reply ); 256 257 if ($reply->{SERVICE_MSG} eq 'GET_PKI_REALM') { 258 my $realm = $self->realm(); 259 if (! $realm ) { 260 $log->fatal("Found more than one realm but no realm is specified"); 261 $log->trace("Realms found:" . Dumper (keys %{$reply->{PARAMS}->{PKI_REALMS}})); 262 die "No realm specified"; 263 } 264 $log->debug("Selecting realm $realm"); 265 my $auth = $self->auth(); 266 $reply = $client->send_receive_service_msg('GET_PKI_REALM',{ 267 PKI_REALM => $realm, 268 (!ref $auth->{stack} ? (AUTHENTICATION_STACK => $auth->{stack}) : ()), 269 }); 270 $self->last_reply( $reply ); 271 } 272 273 if ($reply->{SERVICE_MSG} eq 'GET_AUTHENTICATION_STACK') { 274 my $auth = $self->auth(); 275 276 my $auth_stack; 277 # Option 1: No Auth stack in config - we are screwed 278 if (!$auth->{stack}) { 279 $log->fatal("Found more than one auth stack but no stack is specified"); 280 $log->trace("Stacks found:" . join(" ", keys %{$reply->{PARAMS}->{AUTHENTICATION_STACKS}})); 281 die "No auth stack specified"; 282 283 } 284 285 # Option 2: Single Auth stack in config - take it 286 if (!ref $auth->{stack}) { 287 $auth_stack = $auth->{stack}; 288 289 # Option 3: Mutliple Auth stacks in config 290 # check type against current env for prereqs 291 # Those are currently hardcoded: 292 # type "sso" requires OPENXPKI_USER or REMOTE_USER in ENV 293 # type "x509" requires SSL_CLIENT_CERT 294 # type "passwd" is always selected 295 } else { 296 my $stacks = $reply->{PARAMS}->{AUTHENTICATION_STACKS}; 297 foreach my $stack (@{$auth->{stack}}) { 298 if (!$stacks->{$stack}) { 299 $log->debug("Auth stack $stack in config is not offered by server"); 300 next; 301 } 302 my $stack_type = $stacks->{$stack}->{type} || 'passwd'; 303 if ($stack_type eq 'passwd') { 304 $log->debug("Selecting $stack / passwd"); 305 $auth_stack = $stack; 306 last; 307 } elsif ($stack_type eq 'client') { 308 if (!$ENV{REMOTE_USER} && !$ENV{'OPENXPKI_USER'}) { 309 $log->debug("Skipping $stack / client"); 310 next; 311 } 312 $log->debug("Selecting $stack / client"); 313 $auth_stack = $stack; 314 last; 315 } elsif ($stack_type eq 'x509') { 316 if (!$ENV{SSL_CLIENT_CERT}) { 317 $log->debug("Skipping $stack / x509"); 318 next; 319 } 320 $log->debug("Selecting $stack / x509"); 321 $auth_stack = $stack; 322 last; 323 } else { 324 $log->debug("Skipping $stack / unknown type $stack_type"); 325 } 326 } 327 # failed to select a stack (might be better to use the first or last one as a default?) 328 if (!$auth_stack) { 329 $log->fatal("Mutliple auth stacks given but none matches the prepreqs"); 330 die "No auth stack could be selected specified"; 331 } 332 } 333 334 $log->debug("Selecting auth stack ". $auth_stack); 335 # we send the stack without params which will either return a session 336 # for anonymous stacks or the required parameter list. 337 338 $reply = $client->send_receive_service_msg('GET_AUTHENTICATION_STACK',{ 339 AUTHENTICATION_STACK => $auth_stack, 340 }); 341 $self->last_reply( $reply ); 342 $log->trace("Auth stack request ". Dumper $reply) if $log->is_trace; 343 } 344 345 # FIXME / TODO - most of this code is duplicated in the WebUI Login code 346 if ($reply->{SERVICE_MSG} =~ /GET_(.*)_LOGIN/) { 347 my $login_type = $1; 348 349 my $auth = $reply->{PARAMS}; 350 my $data; 351 # no configuration defined yet 352 if ($login_type eq 'X509') { 353 $data->{certificate} = $ENV{SSL_CLIENT_CERT}; 354 my @chain; 355 # larger chains are very unlikely and we dont support stupid clients 356 for (my $cc=0;$cc<=3;$cc++) { 357 my $chaincert = $ENV{'SSL_CLIENT_CERT_CHAIN_'.$cc}; 358 last unless ($chaincert); 359 push @chain, $chaincert; 360 } 361 $data->{chain} = \@chain if(@chain); 362 363 } elsif ($login_type eq 'CLIENT') { 364 $self->logger()->trace('ENV is ' . Dumper \%ENV) if $self->logger->is_trace; 365 # we reuse the defaults for the old SSO handler from the UI 366 367 if ($auth->{envkeys}) { 368 foreach my $key (keys %{$auth->{envkeys}}) { 369 my $envkey = $auth->{envkeys}->{$key}; 370 $self->logger()->debug("Try to load $key from $envkey"); 371 next unless defined ($ENV{$envkey}); 372 $data->{$key} = $ENV{$envkey}; 373 } 374 # legacy support 375 } elsif (my $user = $ENV{'OPENXPKI_USER'} || $ENV{'REMOTE_USER'} || '') { 376 $data->{username} = $user; 377 $data->{role} = $ENV{'OPENXPKI_GROUP'} if($ENV{'OPENXPKI_GROUP'}); 378 } 379 380 } elsif($login_type eq 'PASSWD') { 381 382 # just add any parameters except stack 383 $data = { %{$self->auth()} }; 384 delete $data->{stack}; 385 386 } else { 387 $log->error("Unsupported login scheme: $login_type"); 388 die "Unsupported login scheme: $login_type. Stopped"; 389 } 390 391 $data = $self->__jwt_signature($data, $reply->{SIGN}) if ($reply->{SIGN}); 392 393 $log->trace("Auth data ". Dumper $data) if $log->is_trace; 394 $reply = $client->send_receive_service_msg('GET_'.$login_type.'_LOGIN', $data ); 395 $self->last_reply( $reply ); 396 } 397 398 if ($reply->{SERVICE_MSG} ne 'SERVICE_READY') { 399 $log->fatal("Initialization failed - message is " . $reply->{SERVICE_MSG}); 400 $log->trace('Last reply: ' .Dumper $reply) if $log->is_trace; 401 die "Initialization failed. Stopped"; 402 } 403 return $client; 404} 405 406 407sub __jwt_signature { 408 409 my $self = shift; 410 my $data = shift; 411 my $jws = shift; 412 413 my $auth = $self->auth(); 414 return unless($auth->{'sign.key'}); 415 $self->logger()->debug('Sign data using key id ' . $jws->{keyid} ); 416 my $pkey = decode_base64($auth->{'sign.key'}); 417 return encode_jwt(payload => { 418 param => $data, 419 sid => $self->backend()->get_session_id(), 420 }, key=> \$pkey, auto_iat => 1, alg=>'ES256'); 421 422} 423 424 425sub run_legacy_command { 426 my $self = shift; 427 my $command = shift; 428 die "run_legacy_command is no longer supported (command $command)"; 429} 430 431sub run_command { 432 433 my $self = shift; 434 my $command = shift; 435 my $params = shift || {}; 436 my $api = shift || 2; 437 438 die "run_command must be called with API version 2 ($command / $api)" if ($api != 2); 439 440 my $reply = $self->client()->send_receive_service_msg('COMMAND', { 441 COMMAND => $command, 442 PARAMS => $params, 443 API => $api 444 }); 445 446 $self->last_reply( $reply ); 447 if ($reply->{SERVICE_MSG} ne 'COMMAND') { 448 my $message; 449 if (my $err = $reply->{'ERROR'}) { 450 if ($err->{PARAMS} && $err->{PARAMS}->{__ERROR__}) { 451 $message = $err->{PARAMS}->{__ERROR__}; 452 } elsif($err->{LABEL}) { 453 $message = $err->{LABEL}; 454 } 455 } else { 456 $message = 'unknown error'; 457 } 458 $self->logger()->error($message); 459 $self->logger()->trace(Dumper $reply) if $self->logger->is_trace; 460 $self->last_error($message); 461 die "Error running command: $message"; 462 } 463 $self->last_error(''); 464 return $reply->{PARAMS}; 465} 466 467=head2 handle_workflow 468 469Combined method to interact with workflows. Action depends on the parameters 470given. Return value is always the workflow info structure. 471 472Legacy Mode: If arguments are passed with uppercase keys (ID, TYPE, ACTIVITY), 473the return structure also contains uppercase keys. This is provided for 474backward compatibility and will be removed with the next major release! 475 476=over 477 478=item id 479 480Returns the workflow info for the existing workflow with given id. 481 482=item activity 483 484Only in combination with ID, executes the given action and returns the 485workflow info after processing was done. Will die if execute fails. 486 487=item type 488 489Create a new workflow of given type, only effective if ID is not given. 490 491=item params 492 493Parameter hash to be passed to create/execute method as input values. 494 495=back 496 497=cut 498 499 500sub handle_workflow { 501 502 my $self = shift; 503 my $params = shift; 504 505 my $reply; 506 # execute exisiting workflow 507 508 my $wf_id = $params->{id}; 509 my $wf_action = $params->{activity}; 510 my $wf_type = $params->{type}; 511 my $wf_params = $params->{params}; 512 513 my $return_uppercase = 0; 514 # legacy mode - uppercase arguments 515 if ($params->{ID} || $params->{TYPE}) { 516 $return_uppercase = 1; 517 $wf_id = $params->{ID} || 0; 518 $wf_action = $params->{ACTIVITY} || ''; 519 $wf_type = $params->{TYPE} || ''; 520 $wf_params = $params->{PARAMS}; 521 } 522 523 if ($wf_action && $wf_id) { 524 525 $self->logger()->info(sprintf('execute workflow action %s on %01d', $wf_action, $wf_id)); 526 $self->logger()->trace('workflow params: '. Dumper $wf_params) if $self->logger->is_trace; 527 $reply = $self->run_command('execute_workflow_activity',{ 528 id => $wf_id, 529 activity => $wf_action, 530 params => $wf_params, 531 }); 532 533 if (!$reply || !$reply->{workflow}) { 534 $self->logger()->fatal("No workflow object received after execute!"); 535 die "No workflow object received!"; 536 } 537 538 $self->logger()->debug('new Workflow State: ' . $reply->{workflow}->{state}); 539 540 } elsif ($wf_id) { 541 542 $self->logger()->debug(sprintf('request for workflow info on %01d', $wf_id)); 543 544 $reply = $self->run_command('get_workflow_info',{ 545 id => $wf_id, 546 }); 547 548 if (!$reply || !$reply->{workflow}) { 549 $self->logger()->fatal("No workflow object received after execute!"); 550 die "No workflow object received!"; 551 } 552 553 $self->logger()->trace(Dumper $reply->{workflow}); 554 555 } elsif ($wf_type) { 556 $reply = $self->run_command('create_workflow_instance',{ 557 workflow => $wf_type, 558 params => $wf_params, 559 ($params->{use_lock} ? (use_lock => $params->{use_lock}) : ()), 560 }); 561 562 if (!$reply || !$reply->{workflow}) { 563 $self->logger()->fatal("No workflow object received after create!"); 564 die "No workflow object received!"; 565 } 566 567 $self->logger()->debug(sprintf('Workflow created (ID: %d), State: %s', 568 $reply->{workflow}->{id}, $reply->{workflow}->{state})); 569 570 } else { 571 $self->logger()->fatal("Neither workflow id nor type given"); 572 die "Neither workflow id nor type given"; 573 } 574 575 $self->logger()->trace('Result of workflow action: ' . Dumper $reply) if $self->logger->is_trace; 576 577 my $ret = $reply->{workflow}; 578 if ($return_uppercase) { 579 my %ret = map { uc ($_) => $reply->{workflow}->{$_} } keys %{$reply->{workflow}}; 580 $ret = \%ret; 581 } 582 583 return $ret; 584} 585 586=head2 disconnect 587 588Close the connection and detach from the communication socket. 589 590=cut 591 592sub disconnect { 593 594 my $self = shift; 595 596 $self->logger()->info('Disconnect client'); 597 598 # Use detach if an external session was provided 599 # otherwise the session will be terminated! 600 if ($self->session()) { 601 $self->client->detach(); 602 } else { 603 $self->client->logout(); 604 } 605 606 $self->client->close_connection(); 607 608 $self->_clear_client(); 609 return $self; 610} 611 612=head2 __reinit_session 613 614Try to reconnect an existing session. Returns the result of init_session 615from the underlying client. 616 617=cut 618 619sub __reinit_session { 620 621 my $self = shift; 622 my $client = shift; 623 624 my $session = $self->session(); 625 if (!$session) { 626 die "Can not reinit backend session without frontend session!"; 627 } 628 629 my $old_session = $session->param('backend_session_id') || undef; 630 $self->logger()->info('old backend session ' . $old_session) if ($old_session); 631 632 my $reply; 633 # Fetch errors on session init 634 eval { 635 $reply = $client->init_session({ SESSION_ID => $old_session }); 636 }; 637 if (my $eval_err = $EVAL_ERROR) { 638 my $exc = OpenXPKI::Exception->caught(); 639 if ($exc && $exc->message() eq 'I18N_OPENXPKI_CLIENT_INIT_SESSION_FAILED') { 640 # The session has gone - start a new one - might happen if the client was idle too long 641 $reply = $client->init_session({ SESSION_ID => undef }); 642 $self->logger()->info('Backend session was gone - start a new one'); 643 } else { 644 $self->logger()->error('Error creating backend session: ' . $eval_err->{message}); 645 $self->logger()->trace($eval_err); 646 die "Backend communication problem"; 647 } 648 } 649 650 my $client_session = $client->get_session_id(); 651 # logging stuff only 652 if ($old_session && $client_session eq $old_session) { 653 $self->logger()->info('Resume backend session with id ' . $client_session); 654 } elsif ($old_session) { 655 $self->logger()->info('Re-Init backend session ' . $client_session . ' / ' . $old_session ); 656 } else { 657 $self->logger()->info('New backend session with id ' . $client_session); 658 } 659 $session->param('backend_session_id', $client_session); 660 $self->logger()->trace( Dumper $session->dataref ) if $self->logger->is_trace; 661 662 return $reply; 663 664} 665 6661; 667 668__END__ 669 670