File Coverage

blib/lib/RADIUS/UserFile.pm
Criterion Covered Total %
statement 19 21 90.4
branch n/a
condition n/a
subroutine 7 7 100.0
pod n/a
total 26 28 92.8


line stmt bran cond sub pod time code
1             package RADIUS::UserFile;
2              
3              
4             =head1 NAME
5              
6             RADIUS::UserFile - Perl extension for manipulating a RADIUS users file.
7              
8             =head1 SYNOPSIS
9              
10             use RADIUS::UserFile;
11              
12             my $users = new RADIUS::UserFile
13             File => '/etc/raddb/users',
14             Check_Items => [ qw(Password Calling-Station-Id) ];
15              
16             $users->load('/usr/local/etc/radius/users');
17            
18             $users->add(Who => 'joeuser',
19             Attributes => { key1 => 'val1', key2 => 'val2' },
20             Comment => 'Created on '. scalar localtime);
21            
22             $users->update(File => '/etc/raddb/users',
23             Who => qw(joeuser janeuser));
24              
25             print $users->format('joeuser');
26              
27             =head1 REQUIRES
28              
29             Perl5.004, Fcntl, File::Copy, Tie::IxHash
30              
31             =head1 EXPORTS
32              
33             Nothing
34              
35             =head1 DESCRIPTION
36              
37             This module provides methods for reading information from and modifying
38             a RADIUS users text file.
39              
40             =head2 PACKAGE METHODS
41              
42             =over 4
43              
44             =item new RADIUS::UserFile
45              
46             =item new RADIUS::UserFile(File => I<$USERS_FILE>, Who => I<$USER>,
47             Check_Items => [ I<@CHECK_ITEMS> ])
48              
49             =item new RADIUS::UserFile(File => I<$USERS_FILE>, Who => [ I<@USERS> ],
50             Check_Items => [ I<@CHECK_ITEMS> ])
51              
52             Creates and returns a new C object.
53              
54             C specifies the RADIUS users file to load (e.g. "/etc/raddb/users").
55             If no file is specified, one isn't loaded; in this case, the C
56             method can be used to retrieve any user data. If an error occurred while
57             reading C, 0 is returned instead.
58              
59             C limits the retrieval of user information to the list of users
60             specified. A single user can be named using a string, or a set of users
61             can be passed as a reference to an array. If Who is left undefined, all
62             users will be loaded.
63              
64             C is a reference to a list of attributes that should be
65             included in the first line of the record. By default, this list includes:
66             "Password", "Auth-Type", "Called-Station-Id", "Calling-Station-Id",
67             "Client-Port-DNIS", and "Expiration".
68              
69             =back
70              
71             =head2 OBJECT METHODS
72              
73             =over 4
74              
75             =item ->add(Who => I<$USER>, Attributes => I<\%ATTRS>, Comment => I<$TEXT>, Debug => I)
76              
77             Adds information about the named user. This information will henceforth
78             be available through C, C, C, etc. Any
79             comments are automatically prefixed with "# ". C should be
80             specified as a reference to a hash; each value should either be an array
81             ref or a string. On success, 1 is returned. On error, 0 is returned
82             and STDERR gets an appropriate message. The debug level is used by the
83             C function described below.
84              
85             =item ->attributes(I<$USER>)
86              
87             Returns a list of defined attributes for the specified user. If the
88             user doesn't exist, undef is returned.
89              
90             =item ->comment(I<$USER>)
91              
92             Returns a string representing the comments that would prefix the given
93             user's entry in the users file. If the user doesn't exist, undef is
94             returned.
95              
96             =item ->debug(I, I<@messages>)
97              
98             Prints out the list of strings in I<@messages> if the debug level is >=
99             I.
100              
101             =item ->dump(I<$USER>)
102              
103             Prints out the attributes of the named user, in alphabetical order.
104             $self is returned.
105              
106             =item ->files
107              
108             Returns a list of files from which we have read user attributes. The list
109             is sorted according to the order in which the files were read. If no
110             files have yet been read successfully, an empty array is returned.
111              
112             =item ->format(I<$USER>)
113              
114             Returns a string containing the attributes of the named user, prefixed by
115             any comments, according to the format required for the RADIUS users file.
116             If the user doesn't exist, an empty string is returned.
117              
118             =item ->load(File => I<$USERS_FILE>, Who => I<$USER>)
119              
120             =item ->load(File => I<$USERS_FILE>, Who => I<\@USERS>)
121              
122             Loads the contents of the specified RADIUS users file. The name of the
123             file is stored in a first-in, last-out stack enumerating which "databases"
124             have been loaded (see C). The C object is
125             returned. The options are the same as described in C. If a
126             user already exists and further info is read about that user from the
127             specified file, the new information is just added to what is already
128             known. On success, 1 is returned; on failure, 0 is returned and an
129             appropriate message is sent to STDERR.
130              
131             =item ->read_users(I<$USERS_FILE>, I<$USER>)
132              
133             =item ->read_users(I<$USERS_FILE>, I<\@USERS>)
134              
135             Reads in the contents of the specified RADIUS users file, and returns
136             a pair of hashes: one indexed by user name, with each element containing
137             a hash of (attribute name => [ values ]) pairs; and another also indexed
138             by user name, containing the comments that immediately preceded that
139             user's file entry. The options are the same as in C. Each
140             comment value is a string. Each user attribute value is a ref to an
141             array of strings. This is mainly designed as a utility function to be
142             used by C and C, and doesn't affect the calling object.
143             On failure, 0 is returned.
144              
145             =item ->remove(I<$USER> ...)
146              
147             Deletes the specified users from the object. The list of users
148             successfully deleted is returned.
149              
150             =item ->removed()
151              
152             Returns a list of users that have been removed from the object.
153              
154             =item ->update(File => I<$USERS_FILE>, Who => I<\@USERS>)
155              
156             Updates user attributes in a RADIUS users file. If the file is
157             specified, its contents are updated; otherwise, the last file read is
158             modified. If a list of users is provided, only their entries are
159             updated; otherwise, all known users are. All users to be "updated"
160             are printed using the results of C. Other users are printed
161             as found. It should be noted that some extra newlines can be left
162             in a file with this method: if an empty line follows a given record
163             that has been Cd, then it will still be there in the file
164             being updated. On success, non-zero is returned. On failure, 0 is
165             returned and STDERR gets an appropriate message.
166              
167             =item ->user(I<$USER>)
168              
169             Returns a ref to a hash representing the attributes of the named user.
170             If the user doesn't exist, undef is returned.
171              
172             =item ->usernames
173              
174             Returns a ref to an anonymous array of strings representing the users
175             about which we have attributes defined. If no users are defined, a ref
176             to an empty anonymous array is returned.
177              
178             =item ->users
179              
180             Returns a ref to a hash of user hashes, where each user hash is a set of
181             (attribute name => value) pairs. This is the actual data stored in the
182             object, so use with caution.
183              
184             =item ->values(I<$USER>, I<$ATTRIBUTE>)
185              
186             Returns an array of strings representing the values for the named
187             attribute of the given user. If the user or attribute doesn't exist,
188             undef is returned.
189              
190             =back
191              
192             =head1 AUTHOR
193              
194             Copyright (c) 2001 O'Shaughnessy Evans .
195             All rights reserved. This version is distributed under the same
196             terms as Perl itself (i.e. it's free), so enjoy.
197              
198             Thanks to Burkhard Weeber, James Golovich, Peter Bannis, and others
199             for contributions and comments that have improved this software.
200              
201             =head1 SEE ALSO
202              
203             L, L, L.
204              
205             =cut
206              
207             require 5.004;
208 1     1   787 use strict;
  1         2  
  1         37  
209 1     1   5 use vars qw($VERSION @ISA @EXPORT @EXPORT_OK);
  1         2  
  1         86  
210 1     1   6 use Carp;
  1         5  
  1         101  
211 1     1   849 use IO::File;
  1         16198  
  1         167  
212 1     1   1037 use File::Copy;
  1         6035  
  1         76  
213 1     1   7 use Fcntl qw(:flock);
  1         2  
  1         162  
214 1     1   2548 use Tie::IxHash;
  0            
  0            
215              
216             require Exporter;
217             @ISA = qw(Exporter);
218             @EXPORT_OK = qw(add attributes comment dump files format load new read_users
219             update user usernames users values);
220              
221             $VERSION = '1.01';
222              
223             #my $RADIUS_USERS = '/etc/raddb/users'; # default users info file
224             my $ATTR_MAX = 31; # max char len of any attribute name
225              
226             my %fields = (
227             users => undef,
228             removed => undef, # cheap hack for remove()
229             files => undef,
230             comments => undef,
231             check_items => undef,
232             ERROR => undef,
233             DEBUG => undef
234             );
235              
236             # Create, initialize, and return a new RADIUS::UserFile object.
237             #
238             sub new
239             {
240             my $me = shift;
241             my $class = ref($me) || $me;
242             my $self = { %fields };
243             bless $self, $class;
244              
245             my %args = @_;
246             return $self->_initialize(\%args);
247             }
248              
249              
250             # Do initial object-creation setup stuff.
251             #
252             sub _initialize
253             {
254             my ($self, $args) = @_;
255              
256             if ($args->{Debug}) {
257             $self->{DEBUG} = $args->{Debug};
258             $| = 1;
259             }
260              
261             if ($args->{Check_Items}) {
262             $self->{check_items} = [ @{$args->{Check_Items}} ];
263             }
264             else {
265             $self->{check_items} = [ "Password", "Auth-Type",
266             "Called-Station-Id", "Calling-Station-Id",
267             "Client-Port-DNIS", "Expiration" ];
268             }
269              
270             if ($args->{File}) {
271             $self->debug(7, "init - loading $args->{File}");
272             my ($users, $comments) = $self->read_users($args->{File}, $args->{Who});
273             return 0 unless defined $comments and defined $users;
274              
275             if ($users) {
276             @{$self->{users}}{keys %$users} = values %$users;
277             @{$self->{comments}}{keys %$comments} = values %$comments;
278             push @{$self->{files}}, $args->{File};
279             }
280             }
281              
282             return $self;
283             }
284              
285              
286             # Adds the specified user to the collection. The arguments provided should
287             # form a hash with the following structure:
288             # 'Who' => "user_name"
289             # 'Attributes' => { key1 => val1, key2 => [ val2 val3 val4 ], ... }
290             # 'Comment' => "optional text to prefix the user's file entry"
291             #
292             # If there is some type of failure, 0 is returned. Otherwise, 1.
293             sub add
294             {
295             my ($self, %args) = @_;
296              
297             unless ($args{Who} and ref $args{Attributes} eq 'HASH') {
298             carp('Insufficient parameters: missing Who or hash of Attributes.');
299             return 0;
300             }
301             $self->debug(6, "add - adding $args{Who}");
302              
303             # Add quotes to each attrib value if it has whitespace and isn't already
304             # quoted.
305             foreach my $k (keys %{$args{Attributes}}) {
306             if (ref $args{Attributes}->{$k} eq 'ARRAY') {
307             for (my $i=0; $i <= $#{$args{Attributes}->{$k}}; $i++) {
308             $args{Attributes}->{$k}[$i] =~ s/^([^"].*[\s,].*)$/"$1"/;
309             }
310             }
311             else {
312             $args{Attributes}->{$k} =~ s/^([^"].*\s.*)$/"$1"/;
313             }
314             }
315              
316             tie %{$self->{'users'}{$args{Who}}}, 'Tie::IxHash'
317             unless tied %{$self->{'users'}{$args{Who}}};
318              
319             foreach my $k (keys %{$args{Attributes}}) {
320             push @{$self->{'users'}{$args{Who}}{$k}},
321             ref $args{Attributes}->{$k} eq 'ARRAY'
322             ? @{$args{Attributes}->{$k}}
323             : $args{Attributes}->{$k}
324             }
325              
326             if (exists $args{Comment}) {
327             $args{Comment} =~ s/^/# /mg;
328             $self->{comments}{$args{Who}} .= $args{Comment}. "\n";
329             }
330              
331             return 1;
332             }
333              
334              
335             # Return a list of defined RADIUS attributes for the specified user.
336             #
337             sub attributes
338             {
339             my ($self, $who) = @_;
340             my @a = eval { local $^W = undef; keys %{$self->{'users'}{$who}} };
341             return $@ ? undef : @a;
342             }
343              
344              
345             # Return the comment text associated with a user.
346             #
347             sub comment
348             {
349             my ($self, $who) = @_;
350             my $text = eval { local $^W = undef; $self->{comments}{$who} };
351             return $@ ? undef : $text;
352             }
353              
354              
355             # Print the attributes of the specified user.
356             #
357             sub dump
358             {
359             my ($self, $who) = @_;
360              
361             return $self unless defined $self->user($who);
362             my @attribs = $self->attributes($who);
363              
364             print "RADIUS user $who:\n";
365              
366             if (@attribs) {
367             foreach my $a (@attribs) {
368             foreach my $v ($self->values($who, $a)) {
369             printf " %-${ATTR_MAX}s => %s\n", $a, $v;
370             }
371             }
372             }
373             else {
374             print " no attributes defined.\n";
375             }
376              
377             return $self;
378             }
379              
380              
381             # Return a ref to a list of files that we have read user info from.
382             #
383             sub files
384             {
385             my $self = shift;
386             my @files = eval { local $^W = undef; @{$self->{files}} };
387             return $@ ? () : @files;
388             }
389              
390              
391             # Return a string containing the attributes for the given user, in the
392             # format acceptable to a RADIUS users file. If the user doesn't exist,
393             # an empty string is returned.
394             sub format
395             {
396             my ($self, $who) = @_;
397              
398             return '' unless defined $self->user($who);
399             my $str = $self->comment($who);
400              
401             my @attribs = $self->attributes($who);
402              
403             # figure out a good way to indent each record
404             my $indent = length($who) + 1;
405             if ($indent < 24) { $indent = 24 }
406              
407             if (@attribs) {
408             my (@attrib_strs);
409             my @checks = ();
410              
411             foreach my $a (@attribs) {
412             foreach my $v ($self->values($who, $a)) {
413             if ($self->_is_check_item($a)) {
414             $self->debug(8, "format - check item $a = $v");
415             push @checks, "$a = $v";
416             }
417             else {
418             push @attrib_strs,
419             sprintf("%s%s = %s", ' 'x$indent, $a, $v);
420             }
421             }
422             }
423             $str .= $who. (' 'x($indent - length $who)). join(', ', @checks). "\n";
424             $str .= join(",\n", @attrib_strs). "\n";
425             }
426              
427             return $str;
428             }
429              
430              
431             # Read user attributes from the specified file. If a set of users is
432             # specified using "Who", the information is limited to those users.
433             #
434             sub load
435             {
436             my ($self, %args) = @_;
437             my $file = $args{File};
438             my $who = $args{Who};
439              
440             my ($users, $comments) = $self->read_users($file, $who);
441             return 0 unless defined $comments and defined $users;
442              
443             foreach my $u (keys %$users) {
444             tie(%{$self->{'users'}{$u}}, 'Tie::IxHash');
445             foreach my $a (keys %{$users->{$u}}) {
446             push @{$self->{'users'}{$u}{$a}}, @{$users->{$u}{$a}};
447             }
448             }
449             foreach my $user (keys %$comments) {
450             $self->{comments}{$user} .= $comments->{$user};
451             }
452             push @{$self->{'files'}}, $file;
453              
454             return 1;
455             }
456              
457              
458             # Read in a radius users file, according to the EBNF provided in
459             # "users-file-syntax.1", distributed w/the Ascend radius server software.
460             # Returns a ref to a hash of user names, where each user element is a hash
461             # of (attribute_name => value) pairs. If a second argument is supplied
462             # ($who), it specifies the set of users to read in... all others in the
463             # file will be ignored. If $who is a string, it is interpreted as a single
464             # user name; if it's a reference to an array, it's interpreted as a set
465             # of user names.
466             #
467             sub read_users
468             {
469             my ($self, $users_file, $who) = @_;
470             my (@fields, $user, %users, $attrib_set, $attrib_input, @who_we_want,
471             %comments, $comment, $attr, $val);
472             local (*USERS);
473              
474             $self->debug(2, "read_users - loading $users_file");
475             open(USERS, $users_file)
476             or carp("Error opening $users_file: $!"), return 0;
477             seek USERS, 0, 0;
478              
479             @who_we_want = ref $who eq 'ARRAY' ? @$who : $who if defined $who;
480              
481             while () {
482             chomp;
483             $self->debug(9, "read_users - in=``$_''");
484             ($comment = '', next) unless $_; # Skip if there's nothing useful,
485             ($comment .= "$_\n", next) if /^#/; # or if it's just a comment.
486              
487             if (/(^[^#,\s]+)\s+(.+)/) { # first line
488             $user = $1;
489             $attrib_input = $2;
490             $comments{$user} = $comment if $comment;
491             tie(%{$users{$user}}, 'Tie::IxHash');
492             $self->debug(5, "read_users - new record $user");
493             }
494             else { # secondary line
495             $attrib_input = $_;
496             }
497              
498             next if @who_we_want and !grep($_ eq $user, @who_we_want);
499              
500             $attrib_set = _parse_attribs($attrib_input, $users_file);
501             while (($attr, $val) = splice @$attrib_set, 0, 2) {
502             push @{$users{$user}{$attr}}, $val;
503             }
504             }
505              
506             close USERS;
507              
508             return (\%users, \%comments);
509             }
510              
511             # Return a ref to a hash of RADIUS users attributes. We assume that
512             # comments have already been stripped from the input string.
513             #
514             sub _parse_attribs
515             {
516             my ($raw, $file) = @_;
517             my @attribs;
518              
519             $raw =~ s/^\s+//; # remove leading whitespace.
520              
521             while ($raw =~ s/^(\S+)\s*=\s*(("[^"]*")|[^",\s]+)\s*,?//) {
522             if (defined $2) {
523             push @attribs, $1, $2;
524             }
525             else {
526             carp("Couldn't understand line $. in `$file'.");
527             last;
528             }
529              
530             $raw =~ s/^\s+//;
531             }
532              
533             return \@attribs;
534             }
535              
536              
537             # Remove the specified users from $self.
538             sub remove
539             {
540             my ($self, @users) = @_;
541              
542             foreach (@users) {
543             delete $self->{'users'}{$_} and push @{$self->{removed}}, $_;
544             delete $self->{comments}{$_};
545             }
546              
547             my @removed = eval { local $^W = undef; @{$self->{removed}} };
548             return $@ ? () : @removed;
549             }
550              
551             sub removed
552             {
553             my $self = shift;
554             my @removed = eval { local $^W = undef; @{$self->{removed}} };
555             return $@ ? () : @removed;
556             }
557              
558              
559             # Update user attributes in a RADIUS users file. The arguments should be
560             # specified as a hash. If the 'File' element is provided, that filename
561             # is used; otherwise, the last file read is used. If the 'Who' element is
562             # provided, only the specified users are updated; otherwise, all known
563             # users are updated.
564             sub update
565             {
566             my ($self, %args) = @_;
567             my $file = exists $args{File} ? $args{File} : $self->{'files'}->[-1];
568             my @who = exists $args{Who}
569             ? (ref $args{Who} eq 'ARRAY' ? @{$args{Who}} : $args{Who})
570             : eval { local $^W = undef; keys %{$self->{users}} };
571             my $temp = "$file.new";
572             local (*IN, *TMP);
573             my $oldsep = $/;
574             local ($/) = ''; # we'll lose multiple blank lines this way
575              
576             carp('No users found'), return 0 unless (@who);
577             _setup_files($file, \*IN, $temp, \*TMP) or return 0;
578            
579             my (%who, @recs, $name, $in);
580             @who{@who} = (0) x @who;
581              
582             while () {
583             undef @recs;
584             $in = $_;
585             while (/^(
586             (?: \#.*\n)* # pre-record comment lines
587             [^\#\s]+.*\n # start of record
588             (?: # rest of record:
589             (?: \s+\S.*\n)| # attribute settings, or
590             ((?: \#.*\n) # comments not followed by another
591             (?! [^\#\s])) # start of record.
592             )*
593             )/goxm) {
594             push @recs, $1;
595             }
596              
597             print(TMP $in), next unless @recs;
598             foreach my $r (@recs) {
599             ($name) = $r =~ /^([^#\s]+)/m;
600              
601             if (!$name) {
602             print TMP $r;
603             }
604             elsif (exists $who{$name}) {
605             $self->debug(6, "update - existing record $name");
606             print TMP $self->format($name) if $who{$name} == 0;
607             $who{$name}++;
608             }
609             elsif (!grep($name eq $_, $self->removed)) {
610             print TMP $r;
611             }
612             }
613             print TMP "\n"; # since the input sep is "\n\n"
614             }
615              
616             # Print out records for anyone we didn't find in $file.
617             foreach (grep($who{$_} == 0, keys %who)) {
618             $self->debug(6, "update - new record $_");
619             print TMP $self->format($_), "\n";
620             }
621              
622             $/ = $oldsep;
623              
624             # Close out input and output files (original and temporary, respectively)
625             _cleanup_files($file, \*IN, $temp, \*TMP) or return 0;
626              
627             return 1;
628             }
629              
630             # Organizational routine for update(). Sets up file handles for reading
631             # from the RADIUS users file. The entire algorithm is like this:
632             # open users file for read/write, creating if necessary
633             # flock file exclusively
634             # compare file opened to file locked, and re-open/lock while not equal
635             # read from file, write to temp (handled in update())
636             # close temp (handled by _cleanup_files)
637             # rename temp to file
638             # close file
639             sub _setup_files
640             {
641             my ($file, $IN, $temp, $TMP) = @_;
642             my $backup = "$file.bak";
643             my $existed = -f $file;
644             my ($dev1, $ino1, $dev2, $ino2);
645              
646             while (1) {
647             open($IN, "+>>$file")
648             or carp("Error opening $file: $!"), return 0;
649             ($dev1, $ino1) = (stat $IN)[0,1];
650              
651             flock($IN, LOCK_EX)
652             or carp("Error locking $file: $!"), close $IN, return 0;
653             ($dev2, $ino2) = (stat $IN)[0,1];
654              
655             last if $dev1 == $dev2 and $ino1 == $ino2;
656             close $IN;
657             }
658              
659             seek $IN, 0, 0;
660             open($TMP, ">$temp")
661             or carp("Error creating $temp: $!"), close $IN, return 0;
662              
663             return 1;
664             }
665              
666             # We should have new content in $TMP, and old content in $IN.
667             # So rename $TMP to $IN and close, releasing the flock on $IN established
668             # in _setup_files().
669             sub _cleanup_files
670             {
671             my ($file, $IN, $temp, $TMP) = @_;
672              
673             close $TMP or carp("Error closing $temp: $!"), return 0;
674             rename($temp, $file) or carp("Error renaming $file to $temp: $!"), return 0;
675             close $IN or carp("Error closing $file: $!"), return 0;
676              
677             return 1;
678             }
679              
680              
681             # See if attribute is a checkable item (Lucent Radius fix -- Peter Bannis)
682             sub _is_check_item
683             {
684             my ($self, $attribute) = @_;
685              
686             if ($attribute) {
687             return grep(/^$attribute$/i, @{$self->{check_items}});
688             }
689             else {
690             return 0;
691             }
692             }
693              
694              
695             # Return a ref to a hash representing the attributes of the specified user.
696             #
697             sub user
698             {
699             my ($self, $who) = @_;
700             my %hash = eval { local $^W = undef; %{$self->{'users'}{$who}} };
701             return $@ ? undef : \%hash;
702             }
703              
704              
705             # Return a ref to a list of users we have RADIUS info for, or a ref to an
706             # empty anonymous array if no users are defined.
707             #
708             sub usernames
709             {
710             my $self = shift;
711             my $users = eval { local $^W = undef; [ keys %{$self->{'users'}} ] };
712             return $@ ? [] : $users;
713             }
714              
715              
716             # Return a ref to a hash of RADIUS users, indexed by user name, each
717             # containing a hash of attributes. This is a ref to the actual data
718             # in the object, so the user information can be changed here.
719             #
720             sub users
721             {
722             my $self = shift; return $self->{'users'};
723             }
724              
725              
726             # Return an array with the values of the given attribute for the named user.
727             #
728             sub values
729             {
730             my ($self, $who, $attr) = @_;
731             my @vals = eval { local $^W = undef; @{$self->{'users'}{$who}{$attr}} };
732             return $@ ? undef : @vals;
733             }
734              
735             sub debug
736             {
737             my ($self, $level, @msg) = @_;
738             if ($level <= $self->{DEBUG}) {
739             print STDERR join("\n", @msg), "\n";
740             }
741             }
742              
743              
744             1;
745              
746             __END__