#! /usr/bin/perl -w use strict; # $Header: /home/nicku/teaching/ict/snm/dhcp-dns-system/dhcp-dns-system-cm/RCS/make-dhcpd.conf,v 1.1 2002/12/12 02:55:10 nicku Exp nicku $ # $Log: make-dhcpd.conf,v $ # Revision 1.1 2002/12/12 02:55:10 nicku # Initial revision # # Revision 1.2 1999/07/29 07:55:40 root # Changed to use more than one spreadheet file. # Added more documentation # Fixed the mac address: putting in colons into a string # of 12 hex digits. # Check that file has at least two tabs. # # Revision 1.1 1999/07/29 04:57:48 root # Initial revision # # Revision 1.2 1999/07/15 03:41:59 nicku # Fixed some documentation errors # Changed debugging levels # Made so fields are not hardcoded, but are read from the Excel spreadsheet. # # Revision 1.1 1999/07/15 01:21:30 nicku # Initial revision # Nick Urbanik # Create a DHCP server configuration file. # Typical use: # make-dhcpd.conf --net 192.168.129/24 tcpipee2.txt > /etc/dhcpd.conf # Written for the ISC DHCP server. # Allocates fixed addresses from information stored about computers in # Excel spreadsheet(s). # The spreadsheet(s) must be saved as "Text (tab delimeted)." # The spreadsheets all have columns with a top row being a header, and the # data in the remaining rows. # There is a companion program that uses the famous h2n program to # generate DNS records for each host from the same spreadsheet. # The manual page can be generated from this file by the command: # pod2man -center " " thisfilename > thisfilename.1 # Suggest put thisfilename in /usr/local/bin, # thisfilename.1 in /usr/local/man/man1 use integer; use Getopt::Long; use File::Basename; use Sys::Hostname; use Socket; use Text::Wrap; use English; my ( @nets, $debug, $serverIP, @dns, $domain, @gateway, @range, $maxlease, $deflease, @wins, # The two main data structures: # %networks is a hash of hashes: info from command line options. # $networks{$net}{"mask"|"gateway"|"range"} # = netmask or gateway or array of lowerIP:upperIP %networks, # %alldata is a hash of hashes: info from spreadsheet. # $alldata{$net}{$numericalIpAddress} = string of info from # one line of spreadsheet %alldata, $maskname, $gatewayname, $range_name, $show_comments, ); $debug = -1; $show_comments = 1; # These are the names of the fields in the hash of hashes %networks: $maskname = "mask"; $gatewayname = "gateway"; $range_name = "range"; # At first, these are in HOURS. $maxlease = 3 * 24; # 3 days. $deflease = 3 * 24; # 3 days. # Should probably default to using current machine as dhcp and dns server. my $hostname = hostname; my $address = gethostbyname( $hostname ) or die "Cannot resolve own hostname: $!"; $hostname = gethostbyaddr( $address, AF_INET ) or die "Cannot re-resolve hostname: $!"; my $defaultdomain; ( $defaultdomain = $hostname ) =~ s/([^.]+)\.(.*)/$2/; my $ownIPaddress = inet_ntoa( $address ); sub Usage() { my $prog = basename( $0 ); print STDERR < /etc/dhcpd.conf USAGE exit 1; } # Note: for more about this pack and unpack stuff, see the Perl # Cookbook, page 48, recipe 2.4. # Also see man Net::netent # Argument: takes an IP address of the form w[.x[.y[.z]]] # where the [.] mean optional. For example, an input of 10 should be # understood to be the network 10.0.0.0, # and the output should be 167772160 sub ip2int($) { my $ip = shift; my @octets = split /\./, $ip; my $num = unpack "N", pack( "C4", @octets ); print STDERR "\$num = $num\n" if $debug > 3; return $num; } # Input is a number in the range 0..32 # The subroutine explicitly ensures that the input is in range # Output is a 32-bit integer. sub bits2num($) { my $bits = shift; die "$bits is out of range 0..32\n" if $bits < 0 || $bits > 32; my $mask = unpack "N", pack( "B32", "1" x $bits . "0" x ( 32 - $bits ) ); return $mask; } # Argument is a 32-bit integer. # output is an IP address of the form w.x.y.z, where w, x, y, z are all # decimal numbers less than or equal to 255. sub int2ip($) { my $num = shift; my $hexnum = sprintf "%x", $num; print STDERR "\$num = $num, \$hexnum = $hexnum in int2ip\n" if $debug > 3; my ( $b3, $b2, $b1, $b0 ) = unpack "C4", pack( "N", $num ); print STDERR "\$b3, \$b2, \$b1, \$b0 = $b3, $b2, $b1, $b0\n" if $debug > 3; return join ".", $b3, $b2, $b1, $b0; } # input is a network specified as w[.x[.y[.z]]]/s # where s is a number in the range 0..32. # s is the number of bits that determine the network part of the address. # The output is a list of two strings, both in the # form w.x.y.z. # For the network number, the host part of the address is all zero. # The subnet mask is of the form 255.255.255.0, where the host # parts of the mask are all zero, and the network bits are set to one. # This subroutine explicitly ands the subnet mask with the network # IP to ensure that they make some sense, and warns if they don't. sub ParseNet($) { my $network = shift; my ( $net, $netbits ) = split "/", $network, 2; my $mask = bits2num( $netbits ); my $unmaskedint = ip2int( $net ); my $maskedint = $unmaskedint & $mask; my ( $a, $b, $c, $d ); $a = int2ip( $maskedint ); $b = int2ip( $unmaskedint ); $c = int2ip( $mask ); warn "WARNING: $net contains host bits if $netbits ", "is the number of network bits.\n" if $maskedint != $unmaskedint; print STDERR "\$net = $net\n", "\$maskedint = $maskedint, \$unmaskedint = $unmaskedint\n", "\$a = $a, \$b = $b, \$c = $c, \$mask = $mask\n" if $debug > 3; return ( int2ip( $maskedint ), int2ip( $mask ) ); } # return true if $ip is in the subnet given by $net and $mask, # false otherwise. sub match_subnet($$$) { my ( $ip, $net, $subnetmask ) = @_; return ( ip2int( $ip ) & ip2int( $subnetmask ) ) == ip2int( $net ); } # See Perl Cookbook, page 30, recipe 1.14 sub trim { my @out = @_; for ( @out ) { next unless defined $_; s/^\s+//; s/\s+$//; } return wantarray ? @out : $out[ 0 ]; } my $cmdLine = "$0 " . join " ", @ARGV; # Allow short options to be bundled together: Getopt::Long::Configure( "bundling" ); GetOptions( "net=s" => \@nets, "n=s" => \@nets, "G:i" => \$debug, "debug:i" => \$debug, "server=s" => \$serverIP, "s=s" => \$serverIP, "D=s" => \@dns, "dns=s" => \@dns, "domain=s" => \$domain, "d=s" => \$domain, "gateway=s" => \@gateway, "g=s" => \@gateway, "range=s" => \@range, "r=s" => \@range, "maxlease=f" => \$maxlease, # in hours "m=f" => \$maxlease, # in hours "deflease=f" => \$deflease, # in hours "l=f" => \$deflease, # in hours "w=s" => \@wins, "wins=s" => \@wins, "comments!" => \$show_comments, ); if ( $debug == 0 ) { # means debug option with no integer argument; turn on at low level. $debug = 1; } elsif ( $debug == -1 ) { # No debug option was selected, so turn debugging off. $debug = 0; } $maxlease *= 3600; # convert from hours to seconds. $deflease *= 3600; # convert from hours to seconds. if ( $debug > 3 ) { foreach my $arg ( @ARGV ) { print STDERR "ARGV: \"$arg\"\n"; } } # Check user has provided at least one network and one filename: warn "ERROR: no networks provided on the command line.\n" unless $nets[ 0 ]; warn "ERROR: no filename\n"unless @ARGV >= 1; Usage unless $nets[ 0 ] and @ARGV >= 1; unless ( $domain ) { $domain = $defaultdomain; warn "Warning: using $domain as default domain\n"; } unless ( $dns[ 0 ] ) { $dns[ 0 ] = $ownIPaddress; warn "Warning: using $ownIPaddress as default name server\n"; } unless ( $serverIP ) { $serverIP = $ownIPaddress; warn "Warning: using $ownIPaddress as default DHCP server\n"; } # Now build the hash of hashes %networks to contain all the options # from the command line that match each subnet. foreach my $net ( @nets ) { my ( $network, $mask ) = ParseNet( $net ); $networks{$network}{$maskname} = $mask; my $found = 0; GW: foreach my $gw ( @gateway ) { if ( match_subnet( $gw, $network, $mask ) ) { $networks{$network}{$gatewayname} = $gw; $found = 1; last GW; } } warn "WARNING: no default gateway for network $net\n" unless $found; $found = 0; RANGE: foreach my $r ( @range ) { my ( $first, $last ) = split /:/, $r; if ( match_subnet( $first, $network, $mask ) ) { unless ( match_subnet( $last, $network, $mask ) ) { warn "WARNING: $first matches $net but $last does not\n"; } else { $found = 1; push @{ $networks{$network}{$range_name} }, "$first:$last"; } } } print STDERR "Warning: no range for dynamic allocation from $net\n" unless $found; print STDERR "$net : \$network = $network, \$mask = $mask\n" if $debug > 3; } # Do this after processing options, but before the while ( <> ) loop: my @excelspreadsheet = @ARGV; foreach ( @excelspreadsheet ) { s/\..*$//; $_ .= ".xls"; } my $spreadsheets = join ", ", @excelspreadsheet; # Here is where we process the Excel spreadsheet output files: my ( $item, $equipType, $brand, $dataPoint, $lab, $subnet_old, $os, $ethernetAddr, # must keep this name $ipAddr, # must keep this name $name, # must keep this name ); my ( $ip_ix, $name_ix, $ether_ix, ); # The column numbers of IP, name, MAC address. my %info; # Column numbers for other info in spreadsheet. LINE: while ( <> ) { # chomp; # Better than using chomp with $/ = "\r\n"; can cope with either line format. s/\r?\n?$//; # The first line of the first file contains the header: if ( $INPUT_LINE_NUMBER == 1 ) { print "Reading file \"$ARGV\"..\n" if $debug; die "File \"$ARGV\" was not saved as \"Text (tabl delimited)\"\n" unless /.*\t.*\t/; # Now parse the headers to get the names of the fields: undef $ip_ix; undef $name_ix; undef $ether_ix; my $header = $_; my @head = split /\t/, $header, -9999; my ( $i, ); for ( $i = 0; $i < @head; ++$i ) { if ( $head[ $i ] =~ /\bip/i ) { die "ERROR: which header in Excel spreadsheet \"$ARGV\" is IP: ", "$head[ $ip_ix ] or $head[ $i ]?\n" if $ip_ix; $ip_ix = $i; } elsif ( $head[ $i ] =~ /\bmac\b|ether|\bhardware\b|network card/i ) { die "ERROR: which header in Excel spreadsheet \"$ARGV\" is ", "ethernet address: $head[ $ether_ix ] or $head[ $i ]?\n" if $ether_ix; $ether_ix = $i; } # Ken chose a weird name for column of host names: elsif ( $head[ $i ] =~ /host\s*name|workstation|M\/C no\./i ) { die "ERROR: which header in Excel spreadsheet \"$ARGV\" is ", "host name: $head[ $name_ix ] or $head[ $i ]?\n" if $name_ix; $name_ix = $i; } else { $info{$head[$i]} = $i; } } die "ERROR: header missing from Excel spreadsheet \"$ARGV\" ", "for host name, IP address or ethernet address\n" unless defined $ip_ix and defined $name_ix and defined $ether_ix; next LINE; } # Now this is a non header line (probably). my @host = split /\t/, $_, -9999; ( $ethernetAddr, $ipAddr, $name ) = @host[ $ether_ix, $ip_ix, $name_ix ]; my ( $label, $ix ); my $infstr = ""; while ( ( $label, $ix ) = each %info ) { my $value = $host[ $ix ]; $value = "unknown" unless defined $value; $infstr .= "$label = $value "; } next unless defined $ethernetAddr; $ethernetAddr = trim( $ethernetAddr ); # Ethernet address needs to be in form u:v:w:x:y:z # Allow ethernet address to be separated by spaces or - signs: $ethernetAddr =~ s/[ -]/:/g; # Also allow ethernet addresses as 12 hex digits, with no spaces: if ( $ethernetAddr =~ /^[\da-f]{12}$/i ) { $ethernetAddr = join ":", unpack "A2" x 6, $ethernetAddr; } # check if have a valid ethernet address: if ( $ethernetAddr !~ /^([\da-f]{1,2}:){5}[\da-f]{1,2}$/i ) { warn "invalid ethernet address \"$ethernetAddr\"\n" if $debug; next; } $ethernetAddr =~ tr/A-Z/a-z/; # Make all ethernet addresses lower case. next unless defined $ipAddr; if ( $ipAddr !~ /^(\d{1,3}\.){3}\d{1,3}$/ ) { warn "invalid IP address \"$ipAddr\"\n" if $debug; next; } unless ( $name ) { warn "no name for $ipAddr\n" if $debug; next; } $name =~ tr/A-Z/a-z/; # translate to lower case # The space here is important, as it separates names from aliases: $name =~ s![^ a-z0-9-]!!g; # remove illegal characters from host name. my $entry = "$name\t$ethernetAddr\t$ipAddr\t$infstr"; # Now identify which subnet this row from the spreadsheet matches: my $found = 0; SUBNET: foreach my $subnet ( keys %networks ) { if ( match_subnet( $ipAddr, $subnet, $networks{$subnet}{$maskname} ) ) { my $ip = ip2int( $ipAddr ); if ( exists $alldata{$subnet}{$ip} and defined $alldata{$subnet}{$ip} ) { if ( $alldata{$subnet}{$ip} eq $entry ) { warn "identical data entered more than once ", "for IP address \"$ipAddr\"\n"; } else { warn "duplicate IP address \"$ipAddr\"\n"; } } $alldata{$subnet}{$ip} = $entry; $found = 1; last SUBNET; } } warn "$ipAddr is not in any subnet you provided\n" unless $found; # This resets $INPUT_LINE_NUMBER so can check the header in each file. # This means we can have different column formats in each file! if ( eof( ARGV ) ) { close ARGV or die "Can't close file $ARGV: $!"; } } my $dnslist = join ", ", @dns; my $winslist = join ", ", @wins; my $formatted_cmdline = wrap( "# ", "# ", $cmdLine ); # Put a backslash at the end of each line: $formatted_cmdline =~ s/$/ \\/gm; # ...then delete it from the end of the last line: $formatted_cmdline =~ s/\\$//s; # This is where we actually generate the dhcp configuration file: print < I<-n net>BI [I] F =head1 DESCRIPTION This program creates a configuration file (usually F) from data read from one or more Excel spreadsheets. It can provide a fixed IP address for each host listed in the spreadsheet(s), and can also allocate ranges of addresses for dynamic allocation. The program reads the information from the spreadsheet(s) and generates a complete F file, replacing the previous configuration file completely. Information comes from two sources: the spreadsheet(s) and the options provided on the command line. Some defaults come from information the program determines about the host on which the program runs. The output of this program is sent to standard output; you need to redirect it into a file. =head2 Main purpose The main purpose of this program is to provide a quick and reliable system for easily incorporating information about clients into the F configuration file. In its current state it supports only fixed addresses for clients, but if users wish it, it could easily be modified to provide support for dynamic address allocation for registered clients only. =head2 Using the same information to update DNS records A further aim is to be able to use the same source of information about clients to create I records for name servers. A program already exists to updates DNS records (built on the B program), but more work remains to make that program as flexible as this one. =head2 Format of the options The options (mostly) are provided in both a long and a short form. The short form takes one hyphen, the long form requires two. Spaces between the short option and its argument are optional; the long form must have either a space or an equals sign separating the option from its argument. Options can be given in any order, including the file name. This behaviour provides standard POSIX syntax for command line options, with GNU extensions. =head2 Format of the Excel spreadsheets The input to this program is one or more files produced by B, saved as I. The first row of each spreadsheet contains a header row. The columns and rows may be put in any order (except for the header row, which I be the first row). The order of the columns in the spreadsheets can be different from each other, even when these differently formatted worksheets are used together in one run of this program. A reasonable way to organise the data is one worksheet for each subnet. =head2 Essential columns in spreadsheet Three columns are essential: one for the I, one for the I and one for the I. I suggest these names as the headings for these columns. Other columns are optional. =over 4 =item I The IP address should be in the form w.x.y.z The heading for this column must contain a word that begins with B (case does not matter). I suggest put leading zeros, using groups of three digits for each octet to make it easy to sort in the Excel spreadsheet, so you can easily see what addresses are available (and which are duplicated!) =item I There are two accepted formats for the hardware address: =over 8 =item nn nn nn nn nn nn i.e., as six hexadecimal bytes separated with spaces. =item nnnnnnnnnnnn i.e., as a sequence of twelve hexadecimal digits. =back The heading for this column must contain any of these words: B, B, B, B. Again, case does not matter. =item I The heading must have both the words I in it. There can be a number of names in this column; we put the canonical name first, then the aliases, in a space-separated list. The heading for this columnd must contain B or B. Case does not matter. =back =head2 Optional columns in spreadsheet(s) Other columns can provide information that can be stored in the configuration file next to the information for that host. Here are some examples of other headings. You can add whatever you think is reasonable. These are just what we use. The program does not depend on these names. The main purpose of these other columns is to provide I, I and I DNS records that can be accessed remotely to provide network administrators with information about machines that may be causing problems. =over 4 =item I Give an item number for each computer, perhaps just to see how many computers you manage! =item I Type of equipment, e.g., Pentium II 350 =item I Brand name and model, e.g., Dell OptiPlex GX1 =item I An identifier for the data point in the wall, to help determine what Ethernet switch or hub the computer is connected to. =item I Where the computer is. =item I Operating system(s) used, e.g., DOS/98/NT/Linux =item I The person to contact when there is some problem with the computer. =back =head1 OPTIONS =over 4 =item B<--comments> Read all the other data from the Excel spreadsheet(s) besides the host name, IP address and Ethernet address, and create a comment for each computer in the configuration file showing this data, just above the entry for that machine. On by default. =item B<--nocomments> Turn off making comments for each machine in the configuration file. =item B<-G> [I], B<--debug>[=I] show more output for higher integer values. If you leave the number out, you get the same as B<-G> I<1>. Anything more than B<--debug>=I<1> is really for debugging the program, and is really only useful to the developer. =item B<-l> I, B<--deflease> I The default lease time. Unfortunately, this is the same for all subnets. The I are a floating point number. =item B<-D> I, B<--dns>=I IP address of a DNS server. You can provide up to three of these, like this: B<--dns>=I<192.168.129.49> B<--dns>=I<192.168.129.50> B<--dns>=I<192.168.129.51> They apply to all subnets. =item B<-d> I, B<--domain>=I The domain that the server will give to each machine. The default is the same as the domain of the machine on which this program is run. =item B<-g> I, B<--gateway>=I The default gateway for a subnet. You may provide as many as you like, up to one for each subnet. The program will take care of matching it with the correct subnet. You can put them in any order. =item B<-m> I, B<--maxlease>=I The maximum lease time, the same for all subnets. I is a floating point value. =item B<-n> IBI, B<--net>=IBI A subnet. There must be one such option for each subnet that the DHCP server allocates IP parameters for. This is the only option which is not optional! Although the program could guess this from the Excel spreadsheet, it would be hard to always get the netmask right, so you need to specify it directly. The I is of the form I<192.168.129> if the network part has 24 bits. The I is an integer giving the number of bits in the network part of the address. So for example, you could specify the old class A network 10.0.0.0 with netmask 255.0.0.0 as I<10>BI<8>, and 192.168.129.0 with netmask 255.255.255.0 as I<192.168.129>BI<24>. =item B<-r> IB<:>I, B<--range>=IB<:>I A range of addresses to be dynamically allocated. The program takes care of putting this into the right subnet. You can have as many of these as you like. This program does not check for overlapping ranges, but B does. =item B<-s> I, B<--server>=I The IP address of the DHCP server. Defaults to the IP address of the machine on which this program is run. =item B<-w> I, B<--wins>=I The address of a WINS server to be given to the clients. You can put more than one of these options. They will be made global to the configuration file. =item F One of more files. Each F is the output of Excel, described above. =back =head1 EXAMPLES make-dhcpd.conf \ --net 192.168/24 \ --net 192.168.100/24 \ --net 192.168.200/24 \ --gateway 192.168.100.254 \ --gateway 192.168.200.1 \ --gateway 192.168.0.254 \ --dns 192.168.0.1 \ --server 192.168.0.1 \ --domain sunday.com \ --range 192.168.0.20:192.168.0.30 \ --range 192.168.0.32:192.168.0.35 \ --range 192.168.0.39:192.168.0.52 \ --range 192.168.100.55:192.168.100.60 \ --range 192.168.100.32:192.168.100.35 \ --range 192.168.100.39:192.168.100.52 \ --range 192.168.200.20:192.168.200.30 \ --range 192.168.200.32:192.168.200.35 \ --range 192.168.200.39:192.168.200.52 \ --nocomments \ named/sunday-ip-address.txt > /etc/dhcpd.conf make-dhcpd.conf \ --wins 202.20.100.226 --wins 192.168.129.100 \ --dns 192.168.129.49 --dns 202.40.209.220 --dns 192.168.129.49 \ --server 192.168.129.49 \ --gateway 192.168.129.254 \ --maxlease 12 \ --deflease 12 \ --range 192.168.129.120:192.168.129.170 \ --net 192.168.129/24 \ --domain tyee.vtc.edu.hk \ tcpipee2.txt > /etc/dhcpd.conf Here is a minimal example: make-dhcpd.conf --net 192.168.129/24 tcpipee2.txt It's probably a good idea to put your options into a file and execute it as a shell script. =head1 AUTHOR Nick Urbanik Please feel free to contact me for any wishes you may have for this program. Please let me know where this documentation is unclear. =head1 BUGS Does not support classes in dhcp version 3. Really mainly for allocating fixed addresses. The lease times apply to all subnets. People may be confused by so many options! Please tell me if you find any more! =head1 COPYRIGHT This is freely distributable and may be freely modified as long as my name is not removed from the program. =head1 SEE ALSO See the documentation for the ISC DHCP server. Also see the URL F I maintain a Red Hat Package of the latest dhcp server; please contact me for it. You can find out what documentation is available by doing: rpm -qld dhcp which will list the documentation files, which includes the RFCs and IETF drafts, as well as the man pages. These include: B(5), B(5), B(5), B(8), B(5), B(8). See also B(2). Programs exist to analyse the dynamically allocated leases. One such program is written in Perl and is called F. =cut