#!/usr/bin/perl -w package ipt_macfilter; # ipt_macfilter script -- manipulate kernel IP tables to redirect all # traffic from unregistered NICs to internal netreg server # Copyright (C) 2004 Ole Craig . All rights # reserved; modification and/or redistribution are permitted under the # terms of the GNU General Public License (version 2 or any later # version, at your option.) If you did not receive a copy of the GPL # with this software, you can find it at # http://www.gnu.org/licenses/gpl.txt =pod po(d)sterity Run on a NAT box that gateways a (e.g. RFC1918) private network space. OK, basic methodology: this program is intended to create and maintain an iptables chain where each rule in the chain matches a "registered" MAC address. If a rule matches, the target is RETURN, i.e. a packet matching that rule stops traversing our chain and goes back to the chain whence it came, to continue traversing it where it left off. If a packet falls off the bottom of our custom matching chain (i.e. it came from a MAC address not matched by any rule) then it jumps to another chain; this other chain is usually just a LOG target and a REDIRECT target, meaning that the packet's original destination is stripped and it is routed instead to the local machine, where we have a netreg (www.netreg.org) server running. The netreg process should call this script upon successful registration, to insert the new MAC into the firewall. Purging of old accounts occurs through a crontab entry, e.g.: 17 3 * * * /usr/local/bin/ipt-macfilter -f; \ /usr/local/bin/ipt-macfilter --addfile /etc/dhcpd.conf Note the addfile argument is simply dhcpd.conf -- this script will parse just about any old file containing MAC addresses, whether in standard a:b:c:d:e:f form, RADIUS aabbcc-ddeeff form, or even bare 12-character (hex) blocks. BEFORE RUNNING: change the values of $IPT and $RESTRICT_IF to match your setup. TODO - Catch EXISTING, RELATED packets? - try --target DNAT --to-destination instead of REDIRECT? (would allow netreg to run on an internal box other than the gateway) Changelog * Tue Apr 27 2004 Ole Craig 1.01 - added readfile capability (--addfile, --delfile opts) - Podified for podsterity :-) * Fri Apr 16 2004 Ole Craig 1.0 - first working release =cut ## setup variables ######################################################## # set nonzero to enable debugging, or run with -d option $debug=0; # iptables command $IPT="/usr/local/sbin/iptables"; # name of firewall interface - i.e. the one facing the restricted network # MAKE SURE YOU CHOOSE THE CORRECT INTERFACE $RESTRICT_IF="eth1"; # name of MAC matching chain $MChain="olc_ipt_macfilter"; # name of redirecting chain $JChain="olc_ipt_macjump"; # lockfile location $lockdir="/var/run"; ## constants -- don't change these without ################################ ## knowing exactly what you are doing ################################ # what iptables built-in chain do we use for insertion into the running # firewall? $FWChain="PREROUTING"; # what iptables table do we use for our chains? $FWTable="nat"; # errors to what fh? $ERR="STDERR"; # output to what fh? $OUT="STDOUT"; ## hug a few trees (clean up the environment :-) ########################## delete @ENV{qw(IFS CDPATH ENV BASH_ENV)}; ########################################################################### ## debugging routine ###################################################### sub debug { $debug && print($ERR "$me: @_ \n"); } ## lock ################################################################### sub lockme { debug "lockme"; use Fcntl; sysopen (LOCKFILE, "$lockfile", O_WRONLY | O_EXCL | O_CREAT) or die "$me: Couldn't lock: stale lockfile in $lockfile?"; print(LOCKFILE "$$\n"); close LOCKFILE and return 1; } ## unlock ################################################################# sub unlockme { debug "unlockme"; if (stat("$lockfile")) { debug "$lockfile exists"; return !(system("/bin/rm", "$lockfile")); # rm and perl disagree on exit values } else { debug "no lockfile found"; return 1; } } ########################################################################### ## complain and exit, removing lockfile ################################### sub shout($) { my $yell=shift; print($ERR "$me: $yell\n"); unlockme and exit 1; } ## verbosity++ ############################################################ sub verbose($) { my $verbiage=shift; $verbose && print($OUT "$verbiage\n"); } ## stub iptables caller ################################################### sub iptables ($) { my @ipcomm=split /\s/, shift; debug "iptables: @ipcomm"; $lasterr=''; open (IPTcomm, "$IPT @ipcomm 2>&1 |") || die("$me: can't fork $IPT: $!"); # want to capture STDERR from iptables, because it may complain on # some of the commands we're feeding it and we want to be able to # pass a failure back to calling procedure while (my $line =) { chomp($line); $lasterr=join(' ', $lasterr, $line); } close(IPTcomm); if ($lasterr !~ /^$/) { debug "iptables: iptables STDERR is \"$lasterr\""; return 0; } else { return 1; } } ## canonicalize possible MAC formats into tolowered(##:##:##:##:##:##) #### sub mac_canon ($) { debug "mac_canon"; my @mac = split /[-:]/, shift; for (@mac) { s/^(\w)$/0$1/g; # zero-pad s/^(\w{6})(\w{6})$/$1:$2/g; #translate bare 12-digit block into radius block s/(\w{2})(\w{2})(\w{2})/$1:$2:$3/g; # handle radius-style blocks } debug "mac_canon: @mac (before lowercasing)"; return (lc(join ":", @mac)); } ## return error if not a valid MAC address in lowercased nn:nn format ##### sub validate ($) { debug "validate"; my $mac=shift; if ($mac =~ /^([0-9a-f]{2}:){5}[0-9a-f]{2}$/) { return $mac; } else { return 0; } } ## read in a file, looking for possible mac addresses ##################### ## return list of valid MACs found ######################################## sub readfile ($) { debug "readfile"; my $file=shift; my @macs=(); if (stat($file)) { open(MYFH, "<", "$file") || shout "$file: can't open for reading! $!\n"; while (my $line = ) { chomp($line); if ($line =~ /^[^\#]*(([\w]{12})|([\w]{6}[-:][\w]{6})|(([\w]{1,2}[:-]){5}[\w]{1,2}))([^\w]|$)+.*/) { if (my $mactest=validate(mac_canon($1))) { verbose "$file: $mactest"; debug "found $mactest in \"$line\""; @macs=(@macs, $mactest); } else { debug "$1 failed validation and canonification in line \"$line\""; } } else { debug "ignoring \"$line\""; } } close(MYFH); } else { shout "$file: nonexistent"; } return @macs; } ## initialize custom chains ############################################### sub chains_init() { debug "chains init"; if (iptables "-t $FWTable --new $JChain") { iptables "-t $FWTable --insert $JChain 1 -j LOG --log-level info --log-prefix \"$JChain\ \" --log-ip-options"; iptables "-t $FWTable --insert $JChain 2 -j REDIRECT"; verbose "Created new iptables chain $JChain"; } else { verbose "Using existing iptables chain $JChain"; } if (iptables "-t $FWTable --new $MChain") { verbose "Created new iptables chain $MChain"; iptables "-t $FWTable --append $MChain -j $JChain"; } else { verbose "Using existing iptables chain $MChain"; } debug "chains init done"; } ## deinitialize custom chain ############################################## sub chains_flush() { my $retval=0; debug "chains_flush"; if (iptables "-t $FWTable --flush $MChain" and iptables "-t $FWTable --delete-chain $MChain") { verbose "$MChain: flushed"; } else { verbose "error flushing $MChain: $!"; $retval++; } if (iptables "-t $FWTable --flush $JChain" and iptables "-t $FWTable --delete-chain $JChain") { verbose "$JChain: flushed"; } else { verbose "error flushing $MChain: $!"; $retval++; } return $retval; } ## read current chain from iptables ####################################### # tie chain entries to line numbers # returns hash of iptables chain linenums keyed to mac address sub mchain_read() { my $i=0; my %mhash=(); debug "mchain_read"; open(CHAIN, "-|", "$IPT", "-t", "$FWTable", "--line-numbers", "--list", "$MChain") || die("$me: can't fork: $!"); while (my $iptline = ) { chomp($iptline); if ($iptline =~ /^(\d+)\s+RETURN\s+.*MAC\s+((?i)[\:[:xdigit:]]+).*/ ) { push @{ $mhash{mac_canon($2)} }, "$1"; debug "mchain_read: $1 $2"; } } foreach $key (sort keys %mhash) { debug "key $key = @{ $mhash{$key} }"; } return %mhash; } ## find current entry point in actual firewall ############################ sub locate_me { debug "locate_me"; open(IPT_IN, "-|", "$IPT", "-t", "$FWTable", "--line-numbers", "--list", "$FWChain", "-n") || die("$me: can't fork: $!"); while (my $iptline = ) { chomp($iptline); if ($iptline =~ /(\d+)\s+([^\s]+)\s+(\w+)\s+([^\s]+)\s+([\d\/.]+)\s+([\d\/.]+)\s+(.+)$/){ my ($lnum, $targ, $prot, $opts, $srce, $dstn, $stff) = ($1, $2, $3, $4, $5, $6, $7); debug "iptables $FWChain: $lnum $targ $prot $opts $srce $dstn $stff"; if ($targ =~ /$MChain/) { verbose "firewall entry point for $MChain found at table $FWChain line $lnum"; return $lnum; } } } verbose "warning: Firewall entry point for \"$MChain\" not found (MAC filtering is not active)"; return 0; } ## insert chains into active firewall tables ############################## sub fw_insert { my $line=locate_me; if ($line) { shout "already active at iptables $FWChain chain, line $line"; } else { if (iptables "-t $FWTable --insert $FWChain 1 --in-interface $RESTRICT_IF --jump $MChain") { verbose "firewall insertion successful"; } else { print($ERR "iptables: $lasterr"); die "unable to insert into running firewall"; } } } # dicey part; this is where we actually manipulate the $FWChain rule ## remove chain from active firewall tables ############################### sub fw_remove { my $line=locate_me; if ($line) { verbose "removing iptables $FWChain chain line $line..."; if (iptables "-t $FWTable --delete $FWChain $line") { verbose "success!"; return $line; } else { shout "FAILED"; return 0; } } else { verbose "refusing to remove nonexistent firewall entry point"; return 0; } } ## add MAC to list ######################################################## # note that this adds directly to iptables -- read_chain will need to # be called again during the current execution if you want to see this # mac on the hashed list. sub add_mac($) { my $mac=shift; debug "add_mac: $mac"; validate($mac) or shout "$mac: not a valid MAC address"; %machash=mchain_read; if ((defined $machash{$mac}) and (my $dval=scalar(@{ $machash{$mac}}) >= 1)) { verbose "$mac: already registered"; return 0; } else { if (iptables "-t $FWTable --insert $MChain 1 -m mac --mac-source $mac -j RETURN") { verbose "$mac added successfully"; } else { shout "$me: Couldn't insert MAC $mac: $!"; } } } ## delete MAC from list ################################################### sub del_mac($) { my $mac=shift; debug "del_mac: $mac"; %machash=mchain_read or shout "Don't seem to have any MACs in table!"; if (exists $machash{$mac} ) { my $present='1'; # remove in reverse order so we don't change position of line # 5 by removing line 4... foreach $linenum (reverse sort @{ $machash{$mac}}) { debug "removing $MChain line $linenum, mac $mac"; verbose "$mac found at line $linenum, removing..."; # we don't use iptables' -D command because we don't trust # that there will be only one instance of a rule matching # a particular MAC. if (!(iptables "-t $FWTable --delete $MChain $linenum")) { shout "unable to remove $mac"; $present='0'; } } return $present; } else { shout "$mac: not in table"; return 0; } } ## main ################################################################### ## EXECUTION BEGINS HERE ################################################## # I suppose I could just use basename, but why bother with another # system call? local @me = split /\//, $0; local $me=$me[-1]; $lockfile="$lockdir/$me"; # we use a hash of line numbers keyed to mac address so that we can # take care of multiple identical entries in the iptables # chain. Shouldn't happen, but there's that annoying gap between # theory and practice... %machash=(); keys %machash=256; # used to capture STDERR (if any) from actual iptables call local $lasterr=''; # documentify use Pod::Usage; =head1 NAME ipt-macfilter - manipulate kernel routing tables to allow gateway throughput or redirect to gateway host =head1 SYNOPSIS ipt-macfilter [--add [,MAC] ] [--del [,MAC] ] ipt-macfilter [ --addfile ] [ --delfile ] ipt-macfilter [--list] [--uniq] ipt-macfilter [--flush] ipt-macfilter [--check ] ipt-macfilter [--verbose] [--INSERT | --REMOVE] [--DEBUG] ipt-macfilter [--help | --man] =head1 OPTIONS =over 4 =item add, a Add specified MAC address (or comma-separated list) to current list of registered MACs. =item delete, d Delete specified MAC address (or comma-separated list) from current list of registered MACs. =item list, l List currently-registered MACs. Reads directly from running firewall. =item check, chk, c Check firewall for specified MAC. =item addfile scan , add anything that looks like a MAC address to current list of registered MACs. =item delfile scan , remove anything that looks like a MAC address from current list of registered MACs. =item flush, f, z Flush (zero out) the firewall's list of registered MACs and delete custom chains. =item uniq, u Sanitize firewall's MAC list by removing duplicate keys (can take awhile!) =item INSERT, I Insert ipt-macfilter hooks into running firewall (enables redirection of unknown MACs) =item REMOVE, R Remove ipt-macfilter hooks from running firewall (disables redirection of unknown MACs) =item verbose, v Increased verbiage output. =item help, h Print short usage and exit =item man, m Print manpage-style usage information =item DEBUG, D Print gobs of runtime debugging information. =back =head1 DESCRIPTION ipt-macfilter creates and manages custom iptables(8) chains with a list of MAC addresses. After a call with "--INSERT", it will hook into the running firewall's PREROUTING table such that packets coming in on the private-network interface from an "unregistered" MAC address will be redirected to the local machine rather than forwarding through the gateway. This is sort of a poor-man's RADIUS, except that it allows heretofore-unknown wireless clients to at least get to a locally-hosted NetReg (www.netreg.org) page. ipt-macfilter is very liberal in what it accepts for a MAC address format. The --addfile and --delfile options can read RADIUS configuration files and dhcpd.conf files, for instance. =cut # handle options -- uses Getopt::Long calls use Getopt::Long qw(:config no_ignore_case bundling); my $man=''; my $help=''; my @addmac=(); my @delmac=(); my $chkmac=''; my $list=''; my $flush=''; my $insert=''; my $deinsert=''; my $uniq=''; my $addfile=''; my $delfile=''; local $verbose=''; if (@ARGV) { shout "$me: couldn't process options: $!" unless GetOptions ('help|?|h' => \$help, 'man|m' => \$man, 'add|a=s' => \@addmac, 'del|d|r=s' => \@delmac, 'check|chk|c=s' => \$chkmac, 'addfile=s' => \$addfile, 'delfile=s' => \$delfile, 'verbose|v' => \$verbose, 'unique|u' => \$uniq, 'DEBUG|D' => \$debug, 'flush|f|z' => \$flush, 'list|l' => \$list, 'INSERT|insert|I' => \$insert, 'REMOVE|R' => \$deinsert) or pod2usage(2); @addmac=split(/,/,join(',',@addmac)); @delmac=split(/,/,join(',',@delmac)); } else { debug "empty ARGV"; $list=1; } if ($debug) { $verbose="1"; debug "verbose=$verbose, debug=$debug"; } if ($help || $man) { pod2usage(-exitstatus => 1, -verbose => 0) if $help; pod2usage(-exitstatus => 0, -verbose => 1) if $man; } lockme; chains_init unless $flush; locate_me; if ($insert) { if ($flush) { shout "-I and -f: options mutually exclusive"; } if ($deinsert) { shout "-I and -R: options mutually exclusive"; } fw_insert; } if ($deinsert) { fw_remove; } if ($addfile) { @addmac=(@addmac, readfile($addfile)); } if ($delfile) { @delmac=(@delmac, readfile($delfile)); } if ($flush) { chains_flush; } foreach $mac (@addmac) { add_mac(validate(mac_canon($mac)) or shout "$mac: not a valid MAC identifier"); } foreach $mac (@delmac) { del_mac(validate(mac_canon($mac)) or shout "$mac: not a valid MAC identifier"); } if ($list || $uniq || $chkmac) { %machash=mchain_read or verbose "No entries found"; if ($chkmac) { if (validate($chkmac=(mac_canon($chkmac)))) { debug "checking $chkmac"; if (defined $machash{$chkmac}) { debug "found $chkmac: $machash{$chkmac}"; verbose "$chkmac: found"; unlockme; exit 0; } else { verbose "$chkmac: not found"; unlockme; exit 1; } } else { shout "$chkmac: not a valid MAC identifier"; unlockme; exit 2; } } foreach $key (sort keys %machash) { if ($uniq) { if (my $dval=scalar(@{ $machash{$key}}) > 1) { verbose "$key has $dval entries, fixing"; del_mac($key) && add_mac($key); } } if ($list) { print "$key\n"; } } } unlockme or die "couldn't remove $lockfile"; debug "finished";