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