File Coverage

blib/lib/App/Foca/Server.pm
Criterion Covered Total %
statement 31 33 93.9
branch n/a
condition n/a
subroutine 11 11 100.0
pod n/a
total 42 44 95.4


line stmt bran cond sub pod time code
1             #
2             # App::Foca::Server
3             #
4             # Author(s): Pablo Fischer (pablo@pablo.com.mx)
5             # Created: 06/13/2012 01:44:57 AM UTC 01:44:57 AM
6             package App::Foca::Server;
7              
8             =head1 NAME
9              
10             App::Foca::Server - Foca server
11              
12             =head1 DESCRIPTION
13              
14             Foca is an application (a HTTP server using HTTP::Daemon) that allows the
15             execution of pre-defined commands via, obviously, HTTP.
16              
17             Well, lets suppose you have a log parser on all your servers and you are in
18             need to parse all of them, the common way would be to ssh to each host (can
19             be as simple as ssh'ing to each host or using a multiplex tool) and execute
20             your parser, but what if your SSH keys or the keys of a user are not there?
21             It will be a heck of pain to enter your password hundred of times or lets
22             imagine you want to parse your logs via some automation (like doing it from
23             an IRC bot or tied to your monitoring solution).. then the problem comes
24             more complex with SSH and private keys. With Foca you don't need to worry
25             about those things, the command will get executed and the output will be
26             returned as a HTTP response.
27              
28             All commands that Foca knows about it are listed in a YAML file. Foca uses a
29             default timeout value for all commands but with this YAML file you can give
30             a specific timeout to a specific command. All commands are executed with IPC
31             (open3).
32              
33             Now the question is.. is Foca secure? Well it depends on you. Depends if you
34             run it as non-root user and the commands you define. Foca will try to do
35             things to protect, for example it will reject all requests that have pipes (|),
36             I/O redirection (>, <, <<, >>), additionally the HTTP request will be validated
37             before it gets executed via the call of C<validate_request()> (L<App::Foca::Server>
38             returns true all the time so if you want to add extra functionality please
39             create a subclass and re-define the method).
40              
41             =head1 EXAMPLE
42              
43             my $server = App::Foca::Server->new(
44             port => $port,
45             commands_file => $commands,
46             commands_timeout => $timeout,
47             debug => $debug);
48              
49             $server->run_server();
50              
51             =head1 EXAMPLE COMMANDS FILE
52              
53             commands_dirs:
54             - /some/path/over/there/bin
55              
56             commands:
57             df_path:
58             cmd: '/bin/df {%foca_args%} | tail -n1'
59             uptime:
60             cmd: '/usr/bin/uptime'
61             'true':
62             cmd: '/bin/true'
63              
64             The way the example commands file work is: First it will look if there is a
65             I<commands_dir> key, this key should have a list of directories (that means
66             it should be an array reference), Foca will look for all executables inside
67             the given directories and add them into memory. Second, it will look for the
68             I<commands> key, this one should be a hash where each key is the name of the
69             command and it should have B<at least> a I<cmd> key which value should be
70             the I<real> command to execute.
71              
72             Please note that when you use the I<commands_dir>, Foca will use the basename
73             of each executable as the name of the command so if you have /usr/local/foo,
74             the foca command will be I<foo> while the command it will execute will be
75             I</usr/local/foo>.
76              
77             Also, you can override commands found in I<commands_dir> via I<commands>, so
78             going back to our /usr/local/foo example, you can have this executable
79             in your /usr/local directory but also have a I<foo> command defined in
80             I<commands>, the one that is defined in I<commands> will be the one that
81             will be used by Foca.
82              
83             Command parameters are accepted but they should be find or declared in
84             the I<Foca-Cmd-Params> HTTP header. L<App::Foca::Client> takes care of
85             preparing the header.
86              
87             Commands can have place-holders, this means that you can define your command
88             in the YAML file and the I<real> command can be a combination of pipes. If your
89             command needs some parameters then you can use I<{%foca_args%}> and it will
90             be replaced with whatever parameters are found in the HTTP header
91             I<Foca-Cmd-Params>.
92              
93             There are two ways to update the list of commands once the server started: One
94             is by obviously restarting it and the other one is via localhost send a
95             HTTP request to localhost:yourport/reload.
96              
97             =cut
98 1     1   27427 use strict;
  1         2  
  1         35  
99 1     1   5 use warnings;
  1         1  
  1         25  
100 1     1   1008 use Cache::FastMmap;
  1         5842  
  1         29  
101 1     1   1048 use Data::Dumper;
  1         9117  
  1         120  
102 1     1   8 use Fcntl;
  1         2  
  1         337  
103 1     1   6 use File::Basename;
  1         2  
  1         66  
104 1     1   819 use FindBin;
  1         1041  
  1         42  
105 1     1   863 use HTTP::Status qw(:constants status_message);
  1         4011  
  1         538  
106 1     1   1120 use IPC::Cmd qw(run_forked);
  1         143722  
  1         70  
107 1     1   1060 use Linux::Proc::Net::TCP;
  1         3432  
  1         33  
108 1     1   538 use Moose;
  0            
  0            
109             use Time::HiRes qw(time);
110             use YAML::Syck qw(LoadFile);
111             # Foca libs/modules
112             use App::Foca::Server::HTTP;
113             use App::Foca::Tools::Logger;
114              
115             =head1 VERSION
116              
117             Version 0.05
118              
119             =cut
120              
121             our $VERSION = '0.05';
122              
123             # Some constants
124             use constant {
125             FOCA_RUN_RC_OK => 100,
126             FOCA_RUN_RC_FAILED_CMD => 200,
127             FOCA_RUN_RC_MISSING_CMD => 300,
128             FOCA_RUN_RC_TIMEOUT_CMD => 400};
129              
130             =head1 Attributes
131              
132             =over 4
133              
134             =item B<commands_file>
135              
136             YAML file with the supported commands.
137              
138             =cut
139             has 'commands_file' => (
140             is => 'rw',
141             isa => 'Str',
142             required => 1);
143              
144             =item B<commands>
145              
146             Hash reference with a list of supported commands. Basically the content of
147             C<commands_file>.
148              
149             =cut
150             has 'commands' => (
151             is => 'ro',
152             isa => 'HashRef');
153              
154             =item B<port>
155              
156             Where to listen for requests?
157              
158             =cut
159             has 'port' => (
160             is => 'rw',
161             isa => 'Int',
162             required => 1);
163              
164             =item B<commands_timeout>
165              
166             Global timeout for all commands. Default to 1min (60 seconds).
167              
168             =cut
169             has 'commands_timeout' => (
170             is => 'rw',
171             isa => 'Int',
172             default => 60);
173              
174             =item B<tmp_dir>
175              
176             Temporary directory, for cache.
177              
178             =cut
179             has 'tmp_dir' => (
180             is => 'rw',
181             isa => 'Str',
182             default => '/tmp');
183              
184             =item B<debug>
185              
186             Debug/verbose mode, turned off by default.
187              
188             =cut
189             has 'debug' => (
190             is => 'rw',
191             isa => 'Bool',
192             default => 0);
193              
194             =item B<server>
195              
196             L<App::Foca::Server::HTTP> object.
197              
198             =cut
199             has 'server' => (
200             is => 'rw',
201             isa => 'Obj');
202              
203             =item B<cache>
204              
205             For mmap cache (so we can share cache across processes).
206              
207             =cut
208             has 'cache' => (
209             is => 'rw',
210             isa => 'Obj');
211              
212             =back
213              
214             =cut
215              
216             =head1 Methods
217              
218             =head2 B<run_server()>
219              
220             Runs the HTTP::Daemon server. it forks on each request.
221              
222             =cut
223             sub run_server {
224             my ($self) = @_;
225              
226             # Do _NOT_ remove this line, this is to make sure we don't leave zombie
227             # processes
228             local $SIG{CHLD} = 'IGNORE';
229              
230             $self->{'server'} = App::Foca::Server::HTTP->new(
231             LocalPort => $self->{'port'},
232             ReuseAddr => 1,
233             Blocking => 1) || die;
234             log_info("Listening on port $self->{'port'}");
235             while(my $connection = $self->{'server'}->accept) {
236             log_connection($connection->peerhost());
237             if (my $pid = fork()) {
238             $connection->close;
239             undef $connection;
240             } else {
241             while (my $request = $connection->get_request) {
242             my $start = time;
243             log_request($connection->peerhost(), $request->uri->path);
244             my $response;
245             # Special commands?
246             if ($connection->peerhost() eq '127.0.0.1') {
247             if ($request->uri->path eq '/reload') {
248             $self->load_commands();
249             $response = $self->build_response(HTTP_OK,
250             "Commands reloaded");
251             } elsif ($request->uri->path eq '/status') {
252             $response = $self->prepare_status_response();
253             }
254             }
255             $response = $self->prepare_foca_response($connection, $request) unless
256             $response;
257             my $lat = (time-$start);
258             # Add latency
259             $response->header('X-Foca-ResponseTime', sprintf("%.5f", $lat));
260             $connection->send_response($response);
261             $connection->close;
262             }
263             exit 0;
264             }
265             }
266             exit 0;
267             }
268              
269             =head2 B<prepare_status_response()>
270              
271             Prepares a response (L<HTTP::Response>) for the /status request. /status
272             requests returns some stats about Foca server, such as: number of active
273             connections, number of closed/zombie connections (user connected and left
274             the connection open with a process that is no longer needed).
275              
276             =cut
277             sub prepare_status_response {
278             my ($self) = @_;
279              
280             my $table = Linux::Proc::Net::TCP->read;
281              
282             my ($active_connections, $closed_connections) = (0, 0);
283             for my $entry (@$table) {
284             if ($entry->local_port == $self->{'port'}) {
285             if ($entry->st eq 'CLOSE_WAIT') {
286             $closed_connections++;
287             } elsif ($entry->st eq 'ESTABLISHED') {
288             $active_connections++;
289             }
290             }
291             }
292            
293             my $body = "active_connections: $active_connections\n";
294             $body .= "closed_connections: $closed_connections\n";
295              
296             return $self->build_response(HTTP_OK, $body);
297             }
298              
299             =head2 B<prepare_foca_response($connection, $request)>
300              
301             Prepares a response (L<HTTP::Response>) for a given foca request (L<HTTP::Request>).
302              
303             =cut
304             sub prepare_foca_response {
305             my ($self, $connection, $request) = @_;
306              
307             my $headers = $request->headers;
308             my $method = $request->method;
309             # Ok, we getting GET or HEAD? the only ones we allow
310             if (grep($method eq uc $_, qw(GET HEAD))) {
311             # We got params?
312             my $params = $headers->header('Foca-Cmd-Params') || '';
313             # *sanitize* the parameters
314             $params = $self->_sanitize_parameters($params);
315             # Ok, which command?
316             my $command = $request->uri->path || '';
317             if ($command =~ m#^/foca/(\S+)(\/)?#) {
318             $command = $1;
319             }
320             # We got command?
321             unless ($command) {
322             return $self->build_response(HTTP_NOT_ACCEPTABLE,
323             "Missing command");
324             }
325             # Cool, now load the commands from memory
326             my $commands = $self->{'cache'}->get('foca_commands');
327             $commands = {} unless $commands;
328             unless ($commands) {
329             log_error("There are no commands available");
330             return $self->build_response(HTTP_NOT_IMPLEMENTED, "No commands available");
331             }
332             # Ok, the command is valid?
333             unless ($commands->{$command}) {
334             return $self->build_response(HTTP_NOT_FOUND, "Unknown command");
335             }
336             # Validate request
337             my ($is_valid, $msg) = $self->validate_request($command, $request);
338             unless ($is_valid) {
339             if ($msg) {
340             return $self->build_response(HTTP_FORBIDDEN, $msg);
341             } else {
342             return $self->build_response(HTTP_FORBIDDEN);
343             }
344             }
345            
346             my ($code, $output) = $self->run_cmd(
347             $connection,
348             $command,
349             $commands->{$command},
350             $params);
351             # Ok, we got a command, now lets
352             if ($code == FOCA_RUN_RC_OK) {
353             return $self->build_response(HTTP_OK, $output);
354             } elsif ($code == FOCA_RUN_RC_TIMEOUT_CMD) {
355             return $self->build_response(HTTP_REQUEST_TIMEOUT, 'Timed out');
356             } else {
357             return $self->build_response(HTTP_INTERNAL_SERVER_ERROR, $output);
358             }
359             }
360             }
361              
362             =head2 B<build_response($code, $body)>
363              
364             Builds a HTTP response (C<HTTP::Response>) based on the given HTTP status code
365             and optionally adds a body.
366              
367             Returns a C<HTTP::Response> so it can be send via the opened connection.
368              
369             =cut
370             sub build_response {
371             my ($self, $code, $body) = @_;
372              
373             my $res = HTTP::Response->new($code, status_message($code));
374              
375             my %default_headers = (
376             pragma => "must-revalidate, no-cache, no-store, expires: -1",
377             no_cache => 1,
378             expires => -1,
379             cache_control => "no-cache, no-store, must-revalidate",
380             content_type => 'text/plain',
381             );
382             while(my($k, $v) = each %default_headers) {
383             $res->header($k, $v);
384             }
385             # A body?
386             $res->content($body) if $body;
387             return $res;
388             }
389              
390             =head2 B<validate_request($command, $request)>
391              
392             re-define this method if you want to add some extra security. By default all
393             requests are valid at this point.
394              
395             =cut
396             sub validate_request {
397             my ($self, $command, $request) = @_;
398              
399             return 1;
400             }
401              
402             =head2 B<run_cmd($connection, $name, $cmd, $params)>
403              
404             Runs whatever the command is and sets a timeout to it. If it takes too long
405             then it will try to kill the process.
406              
407             Depending on the settings given to the command it will return the STDOUT or
408             STDERR or even both. The rules are:
409              
410             =over 4
411              
412             =item 1. On success it will look for STDOUT, if nothing is there then it looks in
413             STDERR. If nothing is foudn in STDERR and STDOUT then an empty string is
414             returned.
415              
416             =item 2. On error it will look for STDERR first, if nothing is there then it
417             looks in STDOUT. If nothing is there then it returns an empty string.
418              
419             =back
420              
421             Both STDOUT and STDERR can be returned if the command is defined as follows:
422              
423             server_uptime:
424             cmd: '/usr/bin/uptime'
425             capture_all: 'y'
426              
427             =cut
428             sub run_cmd {
429             my ($self, $connection, $name, $cmd, $params) = @_;
430              
431             my $output = '';
432             if ($cmd->{'cmd'}) {
433             my $capture_all = 0;
434             if ($cmd->{'capture_all'}) {
435             $capture_all = ($cmd->{'capture_all'} eq 'y');
436             }
437             my @foca_cmd;
438             # For the args, the cmd has a {%args%} parameter?
439             if ($cmd->{'cmd'} =~ /\{\%foca_args\%\}/) {
440             my $cmd = $cmd->{'cmd'};
441             if ($params) {
442             $cmd =~ s/\{\%foca_args\%\}/$params/g;
443             } else {
444             $cmd =~ s/\{\%foca_args\%\}//g;
445             }
446             @foca_cmd = $cmd;
447             } else {
448             @foca_cmd = $cmd->{'cmd'};
449             push(@foca_cmd, $params) if $params;
450             }
451            
452             my $timeout = $cmd->{'timeout'} ?
453             int($cmd->{'timeout'}) : $self->{'commands_timeout'};
454            
455             my ($result, $out, $err, $error_msg, @foca_cmd_pids, $in);
456             eval {
457             my $ip = $connection->peerhost();
458             log_info("Command - $name [timeout: $timeout][ip $ip] - About to run @foca_cmd");
459             $result = run_forked("@foca_cmd", {
460             child_in => \$in,
461             timeout => $timeout});
462             };
463             if ($result->{'timeout'} == $timeout) {
464             return (FOCA_RUN_RC_TIMEOUT_CMD, 'Timed out');
465             }
466             # Ok, sometimes because of SIG{CHLD} we get exit codes of 255
467             # with no stderr which foca thinks the command failed but it really did not,
468             # so lets check if we got stderr too, if we did not then the command was
469             # OK (unless of course there is a real: 'y' command). Check anything >
470             # than 1 cause 1 is by default an error (like /bin/false which wont
471             # return nothing to STDERR...)
472             if ($result->{'exit_code'} > 1) {
473             unless ($result->{'stderr'}) {
474             if (defined $cmd->{'real'}) {
475             if ($cmd->{'real'} ne 'y') {
476             # Force OK
477             $result->{'exit_code'} = 0;
478             }
479             } else {
480             # Force OK
481             $result->{'exit_code'} = 0;
482             }
483             }
484             }
485              
486             if ($result->{'exit_code'} > 0) {
487             my $output = '';
488             if ($capture_all) {
489             $output = $result->{'merged'};
490             } else {
491             if ($out) {
492             $output = $result->{'stdout'};
493             }
494             if ($err) {
495             $output = $result->{'stderr'};
496             }
497             }
498             $output = $result->{'merged'} unless $output;
499             $output = $@ unless $output;
500             $output = $result->{'err_msg'} unless $output;
501             $output =~ s#Can't ignore signal CHLD, forcing to default.(\n)?##g;
502             return (FOCA_RUN_RC_FAILED_CMD, $output);
503             } else {
504             my $output = '';
505             if ($capture_all) {
506             $output = $result->{'merged'};
507             } else {
508             if ($out) {
509             $output = $result->{'stdout'};
510             }
511             if ($err) {
512             $output = $result->{'stderr'};
513             }
514             }
515             $output = $result->{'merged'} unless $output;
516             $output =~ s#Can't ignore signal CHLD, forcing to default.(\n)?##g;
517             return (FOCA_RUN_RC_OK, $output);
518             }
519             } else {
520             return (FOCA_RUN_RC_MISSING_CMD, 'Missing command in commands file');
521             }
522             }
523              
524             =head2 B<load_commands()>
525              
526             Load the commands YAML file and stores it in memory with L<Cache::FastMnap>
527              
528             =cut
529             sub load_commands {
530             my ($self) = @_;
531              
532             log_info("Loading commands from $self->{'commands_file'}");
533            
534             if (-f $self->{'commands_file'}) {
535             my $commands = LoadFile($self->{'commands_file'});
536             if ($commands->{'commands'}) {
537             $self->{'commands'} = $commands->{'commands'};
538             } else {
539             $self->{'commands'} = {};
540             }
541             # We have dirs?
542             if (defined $commands->{'commands_dirs'}) {
543             foreach my $dir (@{$commands->{'commands_dirs'}}) {
544             next unless (-d $dir);
545             foreach my $file (glob("$dir/*")) {
546             next unless (-x $file);
547             my $base = basename($file);
548             if (defined $self->{'commands'}->{$base}) {
549             log_warn("Command $base is already defined");
550             } else {
551             log_debug("Adding $base (fullpath $file)");
552             $self->{'commands'}->{$base} = {
553             'cmd' => $file};
554             }
555             }
556             }
557             }
558             } else {
559             log_error("Commands file does NOT exists");
560             $self->{'commands'} = {};
561             }
562             # Store
563             $self->{'cache'}->set('foca_commands', $self->{'commands'});
564             }
565              
566             ######################## PRIVATE / PROTECTED METHODS ##########################
567             sub BUILD {
568             my ($self) = @_;
569              
570             $self->{'cache'} = Cache::FastMmap->new(
571             share_file => $self->{'tmp_dir'} . '/foca_server_mmap',
572             init_file => 1,
573             empty_on_exit => 1,
574             unlink_on_exit => 1);
575             # Ok, load commands
576             $self->load_commands();
577              
578             init_logger();
579             use_debug() if $self->{'debug'};
580             }
581              
582             sub _sanitize_parameters {
583             my ($self, $parameters_str) = @_;
584              
585             # No pipes
586             $parameters_str =~ s/\|//g;
587             # No quotes
588             $parameters_str =~ s/\'//g;
589             $parameters_str =~ s/\"//g;
590             # No IO redirection
591             $parameters_str =~ s/>//g;
592             $parameters_str =~ s/<//g;
593             # No ;..
594             $parameters_str =~ s/;//g;
595             # Remove backticks
596             $parameters_str =~ s/\`//g;
597             return $parameters_str;
598             }
599              
600             =head1 COPYRIGHT
601              
602             Copyright (c) 2010-2012 Yahoo! Inc. All rights reserved.
603              
604             =head1 LICENSE
605              
606             This program is free software. You may copy or redistribute it under
607             the same terms as Perl itself. Please see the LICENSE file included
608             with this project for the terms of the Artistic License under which
609             this project is licensed.
610              
611             =head1 AUTHORS
612              
613             Pablo Fischer (pablo@pablo.com.mx)
614              
615             =cut
616             1;
617