File Coverage

blib/lib/App/EvalServer.pm
Criterion Covered Total %
statement 19 21 90.4
branch n/a
condition n/a
subroutine 9 9 100.0
pod 1 1 100.0
total 29 31 93.5


line stmt bran cond sub pod time code
1             package App::EvalServer;
2             BEGIN {
3 4     4   346420 $App::EvalServer::AUTHORITY = 'cpan:HINRIK';
4             }
5             BEGIN {
6 4     4   265 $App::EvalServer::VERSION = '0.08';
7             }
8              
9 4     4   41 use strict;
  4         10  
  4         148  
10 4     4   24 use warnings FATAL => 'all';
  4         124  
  4         698  
11              
12             # we want instant child process reaping
13 16     16 1 606683 sub POE::Kernel::USE_SIGCHLD () { return 1 }
14              
15 4     4   5561 use File::Spec::Functions qw;
  4         4676  
  4         501  
16 4     4   6486 use File::Temp qw;
  4         155892  
  4         319  
17 4     4   5272 use POE;
  4         329571  
  4         36  
18 4     4   90223 use POE::Filter::JSON;
  0         0  
  0         0  
19             use POE::Filter::Reference;
20             use POE::Filter::Stream;
21             use POE::Wheel::SocketFactory;
22             use POE::Wheel::ReadWrite;
23             use POE::Wheel::Run;
24             use POSIX qw;
25             use Time::HiRes qw
26              
27             my @inc = map { +'-I' => rel2abs($_) } @INC;
28             my $CHILD_PROGRAM = [
29             $^X, @inc, '-MApp::EvalServer::Child',
30             '-e', 'App::EvalServer::Child::run()'
31             ];
32              
33             my %LANGS = (
34             perl => 'Perl',
35             pl => 'Perl',
36             ruby => 'Ruby',
37             rb => 'Ruby',
38             php => 'PHP',
39             deparse => 'Deparse',
40             python => 'Python',
41             py => 'Python',
42             lua => 'Lua',
43             j => 'J',
44             );
45              
46             sub new {
47             my ($package, %args) = @_;
48             my %defaults = (
49             host => 'localhost',
50             port => 14400,
51             user => 'nobody',
52             timeout => 10,
53             limit => 50,
54             );
55              
56             while (my ($key, $value) = each %defaults) {
57             $args{$key} = $value if !defined $args{$key};
58             }
59              
60             return bless \%args, $package;
61             }
62              
63             sub run {
64             my ($self) = @_;
65              
66             if ($self->{daemonize}) {
67             require Proc::Daemon;
68             eval {
69             Proc::Daemon::Init->();
70             $poe_kernel->has_forked();
71             };
72             chomp $@;
73             die "Can't daemonize: $@\n" if $@;
74             }
75              
76             POE::Session->create(
77             object_states => [
78             $self => [qw(
79             _start
80             _shutdown
81             sig_die
82             fatal_signal
83             server_failure
84             new_client
85             client_read
86             client_write
87             client_error
88             spawn_eval
89             eval_stdin
90             eval_stdout
91             eval_stderr
92             eval_result
93             eval_timeout
94             eval_sig_child
95             )],
96             ],
97             );
98              
99             return;
100             }
101              
102             sub _start {
103             my ($kernel, $self, $session) = @_[KERNEL, OBJECT, SESSION];
104              
105             $self->{server} = POE::Wheel::SocketFactory->new(
106             BindAddress => $self->{host},
107             BindPort => $self->{port},
108             SuccessEvent => 'new_client',
109             FailureEvent => 'server_failure',
110             Reuse => 'yes',
111             );
112              
113             $self->{session_id} = $session->ID;
114             $kernel->sig(DIE => 'sig_die');
115             $kernel->sig(INT => 'fatal_signal');
116             $kernel->sig(TERM => 'fatal_signal');
117             return;
118             }
119              
120             sub sig_die {
121             my ($kernel, $self, $ex) = @_[KERNEL, OBJECT, ARG1];
122             chomp $ex->{error_str};
123              
124             my @errors = (
125             "Event $ex->{event} in session ".$ex->{dest_session}->ID." raised exception:",
126             " $ex->{error_str}",
127             );
128             warn "$_\n" for @errors;
129              
130             $kernel->sig_handled();
131             return;
132             }
133              
134             sub fatal_signal {
135             my ($kernel, $self) = @_[KERNEL, OBJECT];
136             $kernel->yield('_shutdown');
137             $kernel->sig_handled();
138             return;
139             }
140              
141             sub server_failure {
142             my ($self, $operation, $error) = @_[OBJECT, ARG0, ARG2];
143             delete $self->{server};
144             warn "$operation failed: $error\n";
145             return;
146             }
147              
148             sub new_client {
149             my ($self, $handle) = @_[OBJECT, ARG0];
150              
151             my $wheel = POE::Wheel::ReadWrite->new(
152             Handle => $handle,
153             Filter => POE::Filter::JSON->new(),
154             InputEvent => 'client_read',
155             FlushedEvent => 'client_write',
156             ErrorEvent => 'client_error',
157             );
158              
159             $self->{clients}{$wheel->ID} = $wheel;
160             return;
161             }
162              
163             sub client_read {
164             my ($kernel, $self, $input, $wheel_id) = @_[KERNEL, OBJECT, ARG0, ARG1];
165              
166             my $client = $self->{clients}{$wheel_id};
167              
168             if (ref $input ne 'HASH') {
169             $client->put({ error => 'JSON data must be a hash' });
170             }
171             elsif (!defined $input->{lang}) {
172             $client->put({ error => 'No language specified' });
173             }
174             elsif (!defined $LANGS{lc $input->{lang}}) {
175             $client->put({ error => 'Language not supported' });
176             }
177             elsif (!defined $input->{code}) {
178             $client->put({ error => 'Code is missing' });
179             }
180             else {
181             $kernel->yield(
182             'spawn_eval',
183             $wheel_id,
184             $LANGS{lc $input->{lang}},
185             $input->{code},
186             $input->{stdin},
187             );
188             }
189              
190             return;
191             }
192              
193             sub client_write {
194             my ($self, $wheel_id) = @_[OBJECT, ARG0];
195             $self->_remove_client($wheel_id);
196             return;
197             }
198              
199             sub client_error {
200             my ($self, $wheel_id) = @_[OBJECT, ARG0];
201             $self->_remove_client($wheel_id);
202             return;
203             }
204              
205             sub _remove_client {
206             my ($self, $client_id) = @_;
207             delete $self->{clients}{$client_id};
208              
209             for my $eval (values %{ $self->{evals} }) {
210             $eval->{wheel}->kill() if $eval->{client_id} == $client_id;
211             }
212             return;
213             }
214              
215             sub spawn_eval {
216             my ($kernel, $self, $client_id, $lang, $code, $stdin)
217             = @_[KERNEL, OBJECT, ARG0..$#_];
218            
219             my $tempdir = tempdir(CLEANUP => 1);
220             my $result_pipe = catfile($tempdir, 'result_pipe');
221             mkfifo($result_pipe, 0700) or die "mkfifo $result_pipe failed: $!";
222             my $jail = catdir($tempdir, 'jail');
223             mkdir $jail or die "Can't mkdir $jail: $!";
224              
225             my $start_time = time;
226             my $wheel = POE::Wheel::Run->new(
227             Program => $CHILD_PROGRAM,
228             ProgramArgs => [$tempdir, $result_pipe, $jail, $self->{user},
229             $self->{limit}, $lang, $code, $self->{unsafe}],
230             Priority => 10,
231             StdioFilter => POE::Filter::Stream->new(),
232             StderrFilter => POE::Filter::Stream->new(),
233             StdinEvent => 'eval_stdin',
234             StdoutEvent => 'eval_stdout',
235             StderrEvent => 'eval_stderr',
236             );
237             $self->{pid_to_id}{$wheel->PID} = $wheel->ID;
238              
239             if (defined $stdin) {
240             $wheel->put($stdin);
241             }
242             else {
243             $wheel->shutdown_stdin();
244             }
245              
246             open my $pipe_handle, '<', $result_pipe or die "Can't open $result_pipe: $!";
247              
248             my $result_wheel = POE::Wheel::ReadWrite->new(
249             Handle => $pipe_handle,
250             InputEvent => 'eval_result',
251             Filter => POE::Filter::Reference->new(),
252             );
253             $self->{pipe_to_id}{$result_wheel->ID} = $wheel->ID;
254              
255             my $alarm_id = $kernel->delay_set('eval_timeout', $self->{timeout}, $wheel->ID);
256             $self->{evals}{$wheel->ID} = {
257             wheel => $wheel,
258             pipe_wheel => $result_wheel,
259             pipe_name => $result_pipe,
260             client_id => $client_id,
261             alarm_id => $alarm_id,
262             tempdir => $tempdir,
263             start_time => $start_time,
264             return => {
265             stdout => '',
266             stderr => '',
267             output => '',
268             },
269             };
270              
271             $kernel->sig_child($wheel->PID, 'eval_sig_child');
272             return;
273             }
274              
275             sub eval_stdout {
276             my ($self, $chunk, $wheel_id) = @_[OBJECT, ARG0, ARG1];
277              
278             my $eval = $self->{evals}{$wheel_id};
279             $eval->{return}{stdout} .= $chunk;
280             $eval->{return}{output} .= $chunk;
281             return;
282             }
283              
284             sub eval_stderr {
285             my ($self, $chunk, $wheel_id) = @_[OBJECT, ARG0, ARG1];
286              
287             my $eval = $self->{evals}{$wheel_id};
288             $eval->{return}{stderr} .= $chunk;
289             $eval->{return}{output} .= $chunk;
290             return;
291             }
292              
293             sub eval_result {
294             my ($self, $return, $id) = @_[OBJECT, ARG0, ARG1];
295             my $wheel_id = delete $self->{pipe_to_id}{$id};
296             my $eval = $self->{evals}{$wheel_id};
297              
298             while (my ($key, $value) = each %$return) {
299             $eval->{return}{$key} = $value;
300             }
301             return;
302             }
303              
304             sub eval_stdin {
305             my ($self, $wheel_id) = @_[OBJECT, ARG0];
306             my $wheel = $self->{evals}{$wheel_id}{wheel};
307             $wheel->shutdown_stdin();
308             return;
309             }
310              
311             sub eval_sig_child {
312             my ($self, $pid) = @_[OBJECT, ARG1];
313             my $wheel_id = delete $self->{pid_to_id}{$pid};
314              
315             my $eval = delete $self->{evals}{$wheel_id};
316             $poe_kernel->alarm_remove($eval->{alarm_id});
317             unlink $eval->{pipe_name};
318              
319             # getrusage() in the child doesn't provide wallclock time, so we do it
320             $eval->{return}{real_time} = sprintf('%.2f', time() - $eval->{start_time});
321              
322             if (defined $self->{clients}{$eval->{client_id}}) {
323             my $client = $self->{clients}{$eval->{client_id}};
324              
325             if ($eval->{return}{error}) {
326             $client->put({ error => $eval->{return}{error} });
327             }
328             elsif (!exists $eval->{return}{result}) {
329             $client->put({ error => 'Child process died before returning a result.' });
330             }
331             else {
332             $client->put($eval->{return});
333             }
334             }
335              
336             return;
337             }
338              
339             sub eval_timeout {
340             my ($self, $wheel_id) = @_[OBJECT, ARG0];
341             my $wheel = $self->{evals}{$wheel_id};
342             $wheel->kill();
343             return;
344             }
345              
346             sub _shutdown {
347             my ($kernel, $self) = @_[KERNEL, OBJECT];
348             delete $self->{server};
349             delete $self->{clients};
350             $kernel->alarm_remove_all();
351             return;
352             }
353              
354             sub shutdown {
355             my ($self) = @_;
356             $poe_kernel->post($self->{session_id}, '_shutdown');
357             return;
358             }
359              
360             1;
361              
362             =encoding utf8
363              
364             =head1 NAME
365              
366             App::EvalServer - Evaluate arbitrary code in a safe environment
367              
368             =head1 SYNOPSIS
369              
370             use App::EvalServer;
371              
372             my $server = App::EvalServer->new(
373             port => 1234,
374             timeout => 30,
375             );
376              
377             $server->run();
378            
379             # ...
380            
381             $server->shutdown();
382              
383             =head1 DESCRIPTION
384              
385             This application evaluates arbitrary source code in a safe enviroment. It
386             listens on a TCP port and accepts JSON data desribing a language and some
387             code to evaluate. The result of the evaluation and some accompanying
388             information is then returned as back as JSON data. See L and
389             L for details.
390              
391             =head1 METHODS
392              
393             =head2 C
394              
395             Constructs a new C object. Takes the following optional
396             argunments:
397              
398             B<'host'>, the host to listen on (default: 'localhost')
399              
400             B<'port'>, the port to listen on (default: 14400)
401              
402             B<'user'>, evaluate code as this user (default: 'nobody')
403              
404             B<'timeout'>, kill the evaluating process after this many seconds (default: 10)
405              
406             B<'limit'>, resource limit in megabytes (default: 50)
407              
408             B<'daemonize'>, daemonize the process
409              
410             B<'unsafe'>, don't chroot or set resource limits (no root needed). Default is
411             false.
412              
413             =head2 C
414              
415             Runs the server. Takes no arguments.
416              
417             =head2 C
418              
419             Shuts down the server. Takes no arguments.
420              
421             =head1 INPUT
422              
423             To request an evaluation, you need to send a JSON hash containing the
424             following keys:
425              
426             B<'lang'>, a string containing the language module suffix, e.g. 'Perl' for
427             L.
428              
429             B<'code'>, a string containing the code you want evaluated.
430              
431             =head1 OUTPUT
432              
433             When your request has been processed, you will receive a JSON hash back. If
434             no errors occurred B the code was evaluated, the hash will contain the
435             following keys:
436              
437             =over 4
438              
439             =item * B<'result'>, containing the result of the evaluation.
440              
441             =item * B<'stdout'>, a string containing everything that was printed to the
442             evaluating process' stdout handle.
443              
444             =item * B<'stderr'>, a string containing everything that was printed to the
445             evaluating process' stderr handle.
446              
447             =item * B<'output'> a string containing the merged output (stdout & stderr)
448             from the evaluating process.
449              
450             =item * B<'memory'>, the memory use of the evaluating process (as reported by
451             L|BSD::Resource/getrusage>).
452              
453             =item * B<'real_time'>, the real time taken by the evaluating process.
454              
455             =item * B<'user_time'>, the user time taken by the evaluating process.
456              
457             =item * B<'sys_time'>, the sys time taken by the evaluating process.
458              
459             =back
460              
461             If an error occurred before the code could be evaluated, the only key you
462             will get is B<'error'>, which tells you what
463             went wrong.
464              
465             =head1 AUTHOR
466              
467             Hinrik Ern SigurEsson (hinrik.sig@gmail.com), C, and probably
468             others
469              
470             =head1 LICENSE AND COPYRIGHT
471              
472             Copyright 2010 Hinrik Ern SigurEsson
473              
474             This program is free software, you can redistribute it and/or modify
475             it under the same terms as Perl itself.
476              
477             =cut