File Coverage

blib/lib/Net/FTPServer/PWP/Server.pm
Criterion Covered Total %
statement 10 12 83.3
branch n/a
condition n/a
subroutine 4 4 100.0
pod n/a
total 14 16 87.5


line stmt bran cond sub pod time code
1             # -*- perl -*-
2              
3             # Net::FTPServer::PWP::Server - FTP server suitable for PWP services
4              
5             # $Id: Server.pm,v 1.30 2003/04/01 15:50:42 lem Exp $
6              
7             =pod
8              
9             =head1 NAME
10              
11             Net::FTPServer::PWP::Server - The FTP server for PWP (personal web pages) service.
12              
13             =head1 SYNOPSIS
14              
15             ftpd [-d] [-v] [-p port] [-s] [-S] [-V] [-C conf_file]
16              
17             =head1 DESCRIPTION
18              
19             C is a FTP server
20             personality. This personality implements a complete
21             FTP server with special functionalities in order to
22             provide a PWP service implementation.
23              
24             The features provided include:
25              
26             =over
27              
28             =item *
29              
30             Directory quotas
31              
32             =item *
33              
34             Authentication using the RADIUS protocol
35              
36             =item *
37              
38             Configurable root directory
39              
40             =back
41              
42             =head2 CONFIGURATION
43              
44             A few config file entries have been added, as described below:
45              
46             =over
47              
48             =item B
49              
50             If specified, tacks its contents to the root directory obtained
51             through RADIUS. This allows the contraining of the user to a part of
52             her home directory.
53              
54             =item B
55              
56             Defaults to C<-1> or unlimited. Is the number of octets allocated
57             by default to users.
58              
59             =item B
60              
61             Controls how often the FTP server will invalidate its notion of the
62             current space consumption. This allows performance tuning. Use a
63             larger value where a small number of concurrent (same user) sessions
64             are expected. Use a smaller value in the oposite case. Finding out
65             what 'larger' and 'smaller' means is left as an excercise for the
66             reader.
67              
68             A smaller value causes each FTP server to scan the whole user
69             directory more often (actually, every time the number of seconds
70             specified passes).
71              
72             =item B
73              
74             The message to return to the user when her quota is exceeded. Defaults
75             to B.
76              
77             =item B
78              
79             The name of the quota file to use. Defaults to C<../$user-pwpquota>, which
80             places the quota file just above the PWP directory at the home dir of
81             each user using a name composed of the user name plus '-pwpquota'.
82              
83             You can use variables such as C<$hostname>, C<$username>, etc. within
84             its specification. Note that the quota file is specified relative to
85             the PWP directory of the user, but is not subjected to the jail
86             limitations. This allows the quota file to be placed outside the PWP
87             directories.
88              
89             =item B
90              
91             Maximum age in seconds that the quota file can have, before requiring
92             it to be rebuilt.
93              
94             =item B
95              
96             Maximum amount of entries in the quota file before forcing it to be
97             rebuilt.
98              
99             =item B
100              
101             The realm used for authenticating users. Defaults to 'pwp'.
102              
103             =item B
104              
105             RADIUS server (or comma separated list of servers) to send requests
106             to. It is an error to not specify at least, a RADIUS server.
107              
108             =item B
109              
110             The port to direct the RADIUS request. Defaults to 1645.
111              
112             =item B
113              
114             The secret used to authenticate against the RADIUS server. Not
115             specifying it is an error.
116              
117             =item B
118              
119             The RADIUS dictionary file used to encode and decode the RADIUS
120             request. It defaults to C.
121              
122             =item B
123              
124             The amount of time we will wait for an answer from a RADIUS
125             server. After this many seconds, the server is skipped and the next
126             one is tried.
127              
128             =item B
129              
130             The vendor-id used in the Vendor-Specific Attributes sent and received
131             from the RADIUS server. The dafault is 582. The value specified here
132             must match the one used in your dictionary files.
133              
134             =item B
135              
136             When true, instructs the FTP server to attempt to hide the actual
137             mount point from the client. This forms a sort of jail similar to what
138             C imposes, but without the need to replicate system files to
139             the C-ed environment.
140              
141             =back
142              
143             =head1 METHODS
144              
145             =over 4
146              
147             =cut
148              
149             package Net::FTPServer::PWP::Server;
150              
151 1     1   7610 use strict;
  1         3  
  1         39  
152              
153 1     1   5 use vars qw($VERSION $t0);
  1         1  
  1         55  
154              
155             # $t0 is used as a timestamp for the RADIUS response if debug is
156             # enabled
157              
158             $VERSION = '1.21';
159              
160 1     1   843 use IO::Select;
  1         1972  
  1         60  
161 1     1   1711 use Net::FTPServer;
  0            
  0            
162             use NetAddr::IP 3.00;
163             use IO::Socket::INET;
164             use Net::Radius::Packet;
165             use Net::Radius::Dictionary;
166             use Net::FTPServer::PWP::DirHandle;
167             use Net::FTPServer::PWP::FileHandle;
168             use Net::FTPServer::Full::DirHandle;
169             use Time::HiRes qw(gettimeofday tv_interval);;
170              
171             use vars qw(@ISA);
172             @ISA = qw(Net::FTPServer);
173              
174             =pod
175              
176             =item $rv = $self->authentication_hook ($user, $pass, $user_is_anon)
177              
178             Perform login authentication against a RADIUS server. We also take
179             this opportunity to insert our very own handler for the DELE
180             command. This is required to properly keep track of the disk usage of
181             the user. Our handler is called C<_DELE_command> and is documented
182             below.
183              
184             We also hardcode the SITE QUOTA command to allow the user to check her
185             quota. This is done with C<_SITE_QUOTA_command>, documented
186             below. Note that this will conflict with locally defined handlers for
187             the SITE QUOTA command.
188              
189             =cut
190            
191             sub authentication_hook {
192             my $self = shift;
193             my $user = shift;
194             my $pass = shift;
195             my $anon = shift;
196              
197             # $self->log('err', "Authenticating as part of the test");
198             # warn "Authenticating as part of the test\n";
199             # $self->{pwp_root_dir} = '/h/R/e/n/t/lem';
200             # $self->{pwp_quota} = 5_000_000;
201             # $self->{pwp_root_dir} =~ s![^-/\w\._\d]!!g;
202             # $self->{pwp_root_dir} =~ m!^(.*)$!;
203             # $self->{pwp_root_dir} = $1;
204             # $self->{pwp_root_dir} .= '/' unless $self->{pwp_root_dir} =~ m!/$!;
205             # $self->{pwp_root_dir} .= $self->config('pwp root subdir') || '';
206             # $self->{pwp_root_dir} .= '/' unless $self->{pwp_root_dir} =~ m!/$!;
207             # $self->{command_table}{DELE} = \&_DELE_command;
208             # $self->{site_command_table}{QUOTA} = \&_SITE_QUOTA_command;
209             # return 0;
210              
211             $self->log('debug',"Authenticating PWP login $user")
212             if $self->config('debug');
213              
214             return -1 if $anon;
215            
216             if ($self->_auth_client ($user, $pass) == -1) {
217             $self->log('debug',"Authentication failed for $user")
218             if $self->config('debug');
219             return -1;
220             }
221              
222             if ($self->_auth_client ($user, $pass) < 0) {
223             $self->log('debug',"Authentication problem for $user")
224             if $self->config('debug');
225             return -2;
226             }
227              
228             $self->log('debug',"Login $user authenticated")
229             if ($self->config('debug'));
230              
231             # Since everything went well, add our very
232             # own DELE and SITE QUOTA handlers, which
233             # handle the quotas
234              
235             $self->{site_command_table}{QUOTA} = \&_SITE_QUOTA_command;
236             $self->{command_table}{DELE} = \&_DELE_command;
237            
238             return 0;
239             }
240              
241             # subroutine to make string of 16 random bytes
242             sub _bigrand() {
243             pack "n8",
244             rand(65536), rand(65536), rand(65536), rand(65536),
245             rand(65536), rand(65536), rand(65536), rand(65536);
246             }
247              
248             # This is based in the authclient that
249             # ships as an example with the Net::Radius
250             # module.
251              
252             sub _auth_client {
253             my $self = shift;
254             my $user = shift;
255             my $passwd = shift;
256            
257             my $realm = $self->config("radius realm") || 'pwp';
258             my $servport = $self->config("radius port") || 1645;
259             my $secret = $self->config("radius secret");
260             my $timeout = $self->config("radius timeout") || 6;
261             my @servers = split(/\s*,\s*/,$self->config("radius server"));
262             my $dictfile = $self->config("radius dictionary") ||
263             '/usr/local/lib/pwp-dictionary';
264              
265             unless (@servers and $secret) {
266             die "Must specify RADIUS server in config file using ",
267             "'radius server:'\n";
268             }
269            
270             # Parse the RADIUS dictionary file
271             my $dict = new Net::Radius::Dictionary $dictfile
272             or $self->log('err',"Couldn't read dictionary: $!")
273             and return -2;
274            
275             my $ident = int(rand(256));
276              
277             foreach my $server (@servers) {
278              
279             my $ip = new NetAddr::IP $server;
280              
281             my $req; # Our RADIUS request
282             my $rec; # Data from a recv() call
283             my $resp; # Response from the RADIUS server
284              
285             unless ($ip) {
286             $self->log('err',
287             "Can't obtain IP address for $server");
288             next;
289             }
290              
291             # Server socket
292             my $s = new IO::Socket::INET ( PeerHost => $ip->addr,
293             PeerPort => $servport,
294             Proto => 'udp'
295             );
296              
297             unless ($s) {
298             $self->log('err',
299             "Can't create socket for $server");
300             next;
301             }
302              
303             my $sel = new IO::Select;
304             $sel->add($s);
305              
306             # Create a request packet
307             $req = new Net::Radius::Packet $dict;
308             $req->set_code('Access-Request');
309              
310             $req->set_attr('User-Name' => $user . '@' . $realm);
311             $req->set_vsattr($self->config('pwp radius vendor id') || 582,
312             'realm', "$realm\0");
313              
314             $ident += 1;
315             $ident %= 256;
316              
317             $req->set_identifier($ident);
318             $req->set_authenticator(_bigrand); # random authenticator required
319             $req->set_password($passwd, $secret); # encode and store password
320              
321              
322             # Show RADIUS packet to STDERR if debug is on
323              
324             $t0 = [gettimeofday]; # Used to time responses or lack of.
325              
326             if ($self->config("debug")) {
327             warn "Request RADIUS packet \n", $req->str_dump;
328             }
329              
330             # Send to the server. Encoding with auth_resp is NOT required.
331             unless ($s->send($req->pack)) {
332             $self->log('err', "Failed to send request to $server: $!");
333             next;
334             }
335            
336             # wait for response and potentially, retry if too many time
337             # elapses...
338              
339             unless ($sel->can_read($timeout))
340             {
341             my $elapsed = sprintf("%0.04f", tv_interval ( $t0 ));
342             $self->log('warning',
343             "Timeout on RADIUS server $server after " .
344             "$elapsed seconds");
345             next;
346             }
347              
348             unless (defined $s->recv($rec, 8192)) {
349             $self->log('err',
350             "Problem receiving packet from $server: $!");
351             next;
352             }
353              
354             $resp = new Net::Radius::Packet $dict, $rec;
355            
356             # RADIUS packet debugging
357             if ($self->config("debug")) {
358             my $elapsed = sprintf("%0.04f", tv_interval ( $t0 ));
359             warn "Response RADIUS packet in $elapsed seconds\n",
360             $resp->str_dump;
361             }
362              
363             # XXX - Our check should be stronger than
364             # this...
365              
366             if ($resp->identifier != $ident) {
367             $self->log('warning',
368             "Got answer from $server with invalid identifier");
369             next;
370             }
371              
372             if ($resp->code eq 'Access-Accept') {
373             my $vsa;
374              
375             # Extract the home directory from the RADIUS
376             # response
377              
378             $vsa = $resp->vsattr($self->config('pwp radius vendor id') || 582,
379             'homedir');
380              
381             $self->{pwp_root_dir} = $vsa->[0] if $vsa;
382              
383             # Extract the quota from the RADIUS response
384              
385             $vsa = $resp->vsattr($self->config('pwp radius vendor id') || 582,
386             'quota');
387              
388             $self->{pwp_quota} = $vsa->[0] * 1_000_000 if $vsa;
389            
390             unless ($self->{pwp_root_dir}) {
391             $self->log('warning',
392             "Did not receive home directory from RADIUS\n");
393             return -1;
394             }
395            
396             # This untaints the directory and insures
397             # a sane path
398              
399             $self->{pwp_root_dir} =~ s![^-/\w\._\d]!!g;
400             $self->{pwp_root_dir} =~ m!^(.*)$!;
401             $self->{pwp_root_dir} = $1;
402            
403             $self->{pwp_root_dir} .= '/' unless $self->{pwp_root_dir} =~ m!/$!;
404             $self->{pwp_root_dir} .= $self->config('pwp root subdir') || '';
405             $self->{pwp_root_dir} .= '/' unless $self->{pwp_root_dir} =~ m!/$!;
406            
407             return 0;
408             }
409             }
410             return -1;
411             }
412              
413             =pod
414              
415             =item $self->user_login_hook ($user, $anon)
416              
417             Hook: Called just after user C<$user> has successfully logged in.
418              
419             =cut
420              
421             # According to the doco, this is called
422             # after a succesful login. We'll use this
423             # oportunity to get the quota info
424              
425             sub user_login_hook {
426              
427             my $self = shift;
428             my $user = shift;
429             my $anon = shift;
430              
431             $self->{pwp_quota} = $self->config('default pwp quota') || -1
432             unless $self->{pwp_quota};
433              
434             $self->{pwp_qliveness} = $self->config('pwp quota cache secs') || 60
435             unless $self->{pwp_qliveness};
436              
437             $self->{pwp_max_qfile_age} = $self->config('pwp max quota file age')
438             || 48 * 3600;
439              
440             $self->{pwp_max_qfile_entries} = $self->config('pwp max quota file lines')
441             || 10;
442              
443             # Apply variable substitution to
444             # the qfile spec so that they can be
445             # placed anywhere
446              
447             $self->{pwp_qfile} = $self->config('pwp quota file') || '../pwpquota';
448             $self->{pwp_qfile} =~ s!\$(\w+)!$self->{$1}!g;
449              
450             my $uid = $self->config('default pwp userid');
451             my $gid = $self->config('default pwp groupid');
452              
453             if (defined $uid and defined $gid) {
454              
455             # XXX - This function is not documented
456              
457             eval { $self->_drop_privs($uid, $gid, $user); };
458             }
459              
460             }
461              
462             =pod
463              
464             =item $dirh = $self->root_directory_hook;
465              
466             Hook: Return an instance of Net::FTPServer::PWPDirHandle
467             corresponding to the root directory.
468              
469             =cut
470              
471             # Set the root directory for this user and
472             # also calc the usage if required
473             sub root_directory_hook {
474             my $self = shift;
475              
476             $self->{pwp_root} =
477             new Net::FTPServer::PWP::DirHandle($self, '/');
478              
479             $self->log('debug', "root_directory_hook: root is $self->{pwp_root_dir}")
480             if $self->config('debug');
481              
482             $self->_add_space(0); # If the quota file is too old, force
483             # its rebuilding.
484              
485             return $self->{pwp_root};
486             }
487              
488             # Calculate current space utilization
489             # and update post file
490             sub _calc_space {
491             my $self = shift;
492              
493             $self->{pwp_space} = 0;
494              
495             unless ($self->{pwp_qhandle}) {
496             $self->{pwp_qhandle} = $self->{pwp_root};
497              
498             # The quota file might be wanted outside
499             # the current home directory. Therefore,
500             # we need to be free from the hurdles
501             # of 'hide mount point'...
502              
503             $self->{pwp_qhandle} = Net::FTPServer::Full::DirHandle
504             -> new($self, $self->{pwp_qhandle}->{_pathname});
505              
506             my @parts = split m!/!, $self->{pwp_qfile};
507              
508             while (my $c = shift @parts) {
509             next if $c eq '' or $c eq '.';
510             if ($c eq "..") {
511             $self->{pwp_qhandle} = $self->{pwp_qhandle}->parent;
512             }
513             else {
514             my $h = $self->{pwp_qhandle}->get($c);
515              
516             if (!$h and !@parts) {
517             $h = $self->{pwp_qhandle}->open($c, "w");
518             unless ($h) {
519             warn "Cannot create quota file ",
520             $self->{pwp_qhandle}->pathname . "/$c: $!\n";
521             delete $self->{pwp_qhandle};
522             return undef;
523             }
524             }
525              
526             $self->{pwp_qhandle} = $self->{pwp_qhandle}->get($c);
527              
528             unless
529             ($self->{pwp_qhandle} and
530             $self->{pwp_qhandle}->isa("Net::FTPServer::Handle"))
531             {
532             warn "Invalid quota file: $self->{pwp_qfile} ($!)\n";
533             delete $self->{pwp_qhandle};
534             return undef;
535             }
536             }
537             }
538              
539             unless
540             ($self->{pwp_qhandle}
541             and $self->{pwp_qhandle}->isa("Net::FTPServer::FileHandle"))
542             {
543             warn "$self->{pwp_qhandle} Quota file seems invalid: $self->{pwp_qfile}\n";
544             delete $self->{pwp_qhandle};
545             return undef;
546             }
547             }
548              
549             my $fh = $self->{pwp_qhandle}->open("w");
550              
551             unless ($fh) {
552             die "Failed to create ", $self->{pwp_qfile}, ": $!\n";
553             }
554              
555             $self->visit($self->{pwp_root}, {
556             'f' => sub
557             {
558             # warn "quota f: ", $_->pathname, " adds ", ($_->status)[5], "\n";
559             $self->{pwp_space} += ($_->status)[5]
560             unless $_->pathname
561             eq $self->{pwp_qhandle}->pathname;
562             return 1;
563             },
564             'd' => sub
565             {
566             # warn "quota d: ", $_->pathname, " adds ", ($_->status)[5], "\n";
567             $self->{pwp_space} += ($_->status)[5]
568             unless $_->pathname
569             eq $self->{pwp_qhandle}->pathname;
570             return 1;
571             },
572             'l' => sub
573             {
574             # warn "quota l: ", $_->pathname, " adds ", ($_->status)[5], "\n";
575             $self->{pwp_space} += ($_->status)[5]
576             unless $_->pathname
577             eq $self->{pwp_qhandle}->pathname;
578             return 1;
579             }
580             });
581            
582             print $fh $self->{pwp_space}, "\n";
583              
584             # warn "Quota file rebuilt. $self->{pwp_space} bytes seen\n";
585              
586             $fh->close;
587              
588             $self->{pwp_quota_stamp} = time;
589             }
590              
591             # Add $size bytes to the current space
592             # utilization, potentially regenerating
593             # the post file
594             sub _add_space {
595             my $self = shift;
596             my $size = shift;
597              
598             # Try to find the quota file in the fs
599              
600             # warn "_add_space $size\n";
601              
602              
603             $self->_calc_space unless $self->{pwp_qhandle};
604              
605             my $fh = $self->{pwp_qhandle};
606              
607             if ($fh) {
608              
609             my $lines = 0;
610              
611             $self->{pwp_space} = 0;
612              
613             my $stamp = ($fh->status)[6];
614              
615             # Update the quota info if the stamp
616             # expires or is updated behind our back
617              
618             $self->{pwp_quota_stamp} = $stamp
619             unless $self->{pwp_quota_stamp};
620              
621             # warn "qstamp=$self->{pwp_quota_stamp}\n";
622             # warn "qmax=$self->{pwp_max_qfile_age}\n";
623             # warn "stamp=$stamp\n";
624             # warn "time=", time, "\n";
625              
626             if ($stamp + $self->{pwp_max_qfile_age} < time
627             or $stamp > $self->{pwp_quota_stamp})
628             {
629             # warn "case 1\n";
630             $self->_calc_space;
631             }
632             else {
633             my $f = $fh->open("r");
634             while (my $bytes = <$f>) {
635             chomp $bytes;
636             next unless $bytes;
637             $self->{pwp_space} += $bytes;
638             if ($self->{pwp_max_qfile_entries} > 0
639             and ++$lines > $self->{pwp_max_qfile_entries})
640             {
641             # warn "case 2\n";
642             $self->_calc_space;
643             last;
644             }
645             }
646             $f->close;
647             }
648             }
649             else {
650             # warn "case 3\n";
651             $self->_calc_space;
652             }
653              
654             if ($size != 0) {
655             # Add the space to the quota file only
656             # if non-zero.
657              
658             $fh = $self->{pwp_qhandle}->open("a");
659              
660             die "Failed to append quota file ", $self->{pwp_qfile}, ": $!\n"
661             unless $fh;
662              
663             print $fh $size, "\n";
664              
665             $fh->close;
666             $self->{pwp_quota_stamp}
667             = ($self->{pwp_qhandle}->status)[6];
668              
669             }
670              
671             $self->{pwp_space} += $size;
672             }
673              
674             =pod
675              
676             =item $dirh = $self->pre_command_hook;
677              
678             Hook: Insures that our quotas look sane enough. Otherwise, have them
679             recalculated.
680              
681             =cut
682              
683             sub pre_command_hook {
684             my $self = shift;
685             return unless defined $self->{pwp_quota} and $self->{pwp_quota} > 0;
686             $self->_calc_space if $self->{pwp_space} <= 0;
687             }
688              
689             =pod
690              
691             =item $dirh = $self->transfer_hook;
692              
693             Hook: Enforce the quota mechanism by seeing that no transfer exceed
694             the allocated quota.
695              
696             =cut
697              
698              
699             # This hook is used to enforce the quotas
700             # XXX - When this hook is called, there is
701             # already a file created in the VFS. We
702             # seem to be unable to access it, so we
703             # cannot erase it.
704             sub transfer_hook {
705             my $self = shift;
706             my $mode = shift;
707             my $file = shift;
708             my $sock = shift;
709             my $rbuf = shift;
710              
711             return undef unless $mode eq 'w';
712              
713             if ($self->{pwp_quota} > 0) {
714             unless (defined $self->{pwp_space}
715             and defined $self->{pwp_quota_stamp}
716             and defined $self->{pwp_qliveness}
717             and $self->{pwp_quota_stamp}
718             + $self->{pwp_qliveness} > time)
719             {
720             # Update the quota information
721              
722             $self->_add_space(0);
723             }
724              
725             my $len = length $$rbuf;
726              
727             if ($self->{pwp_space} + $len > $self->{pwp_quota}) {
728             return $self->config('pwp quota exceeded message')
729             || "This operation would exceed your quota";
730             }
731             $self->_add_space($len);
732             }
733              
734             return undef; # OK by default
735              
736             }
737              
738             =pod
739              
740             =item _SITE_QUOTA_command();
741              
742             This method handles the C command, that allows the user to
743             check at a glance, what the server thinks of its space usage.
744              
745             =cut
746              
747             sub _SITE_QUOTA_command {
748             my $self = shift;
749             my $cmd = shift;
750             my $rest = shift;
751              
752             if ($self->{pwp_quota} > 0) {
753             $self->reply(200, $self->{pwp_space}
754             . " out of " . $self->{pwp_quota}
755             . " bytes of quota used.");
756             } else {
757             $self->reply(200, "No quotas for this account.");
758             }
759             return;
760             }
761              
762             =pod
763              
764             =item _DELE_command();
765              
766             This is supposed to intercept C before it
767             is called. What we do here, is to note the size of the
768             soon-to-be-deleted file and apply the change in the quota file if the
769             operation was succesful.
770              
771             Note that this might be somewhat dangerous or un-portable as
772             traditionally, method names starting with C<_> mean internal things
773             that should not be messed from the outside. Yet it seems we do not have
774             a better solution to this issue.
775              
776             The code contains a race condition: If two different sessions try to
777             delete the same file at the same time, probably both will think they
778             did and will attempt to reflect this in the quota file. There's a
779             chance for both of the updates to make it to the quota file, thus
780             over-reducing the user's space allocation. This will correct
781             automatically after either a few more operations or some time.
782              
783             =cut
784              
785             sub _DELE_command {
786             my $self = shift;
787             my $cmd = shift;
788             my $rest = shift;
789              
790             my ($o_fileh) = ($self->_get ($rest))[1];
791              
792             my $size = 0;
793             my $mode;
794              
795             if ($o_fileh) {
796             ($mode, $size) = ($o_fileh->status)[0, 5];
797             }
798            
799             $self->SUPER::_DELE_command($cmd, $rest);
800              
801             my ($n_fileh) = ($self->_get ($rest))[1];
802              
803             if ($o_fileh and not $n_fileh and $mode ne 'd') {
804             # File was actually deleted
805             $self->_add_space(-$size);
806             }
807              
808             return;
809             }
810              
811             1;
812              
813             __END__