header image

Setting up a FreeBSD Wireless Access Point

Scott Gasch

This article describes how to set up an open wireless access point on FreeBSD 4.9 including the creation of a "captive portal" and bandwidth limiting using IPFIREWALL.

Samples are included along with the descriptions of how it all works. But, because my setup here is probably different than what you have there, you'll probably have to adapt this stuff to your needs. This article assumes a basic familiarity with FreeBSD, ipfw and perl programming. Please feel free to email me with questions or problems. Also I'm, of course, interested in hearing about errors, omissions, improvements, and success stories!

This document is available in several formats:




1. Introduction

When I set up an open wireless access point at my house I was a little worried. Not because I thought I'd get hacked; my wireless subnet is totally separated from my internal network by a FreeBSD firewall. And I wasn't worried about my ISP getting pissed because are actually okay with me sharing my bandwidth. I was worried about two things:

  • Random people sucking up all my bandwidth.

  • People sending spam, raising hell, etc from my IP address.

This document outlines my approach to handling both problems. It is also a technical guide to setting up a "captive portal" and bandwidth limiting on FreeBSD-4.9.


2. Captive Portal

If you've ever been at a hotel where they charge for Internet access you know what a captive portal is. You plug your laptop into the network but you can't do anything until you open your browser and type in your credit card number on the only webpage you can get to. I wanted the same thing (minus the credit card number, plus an acceptable use notice) on my open wireless access point. People who want to use my bandwidth can do it for free as long as they click on the "I ACCEPT" link and agree not to spam, trade warez, etc. Whether I think this will make any difference at all is another question for a different article.

To accomplish this I used FreeBSD's IPFIREWALL kernel option and the userland ipfw utility. My FreeBSD machine was already set up with a firewall and was running NAT. It had one outside static IP address and one IP address on my inside private network. I added a third network card to the machine and hung the wireless access point off this new card. I decided on a range of private (rfc1918) IP addresses for the wireless subnet (different from the private range I was already using on the inside network) and set up my DHCP server to dole out IPs to clients on both networks.

I'll describe how I added a captive portal to this setup but if you need a primer on how to get your basic FreeBSD firewall setup, check out http://www.freebsddiary.org/ipfw.php.

The executive summary of what I did is:

  1. install a firewall rule that diverts all wireless http traffic to a webserver listening on the wireless-subnet interface of the FreeBSD machine.

  2. install a firewall rule that blocks all other wireless traffic.

  3. install a CGI script on the webserver listening on the wireless-subnet that shows my acceptable use policy.

  4. When someone SUBMITs on the form served by this CGI script, add a rule to the firewall for their IP address that bypasses the rules in the first two steps (above).

Here's the full sample rc.firewall rules. This is also included in the tarball at the end of this article:

#
# Some sample firewall rules
#
${fwcmd} -f flush
${fwcmd} add 100 pass all from any to any via lo0
${fwcmd} add 200 deny all from any to 127.0.0.0/8
${fwcmd} add 300 deny ip from 127.0.0.0/8 to any

#               NETWORK TOPOLOGY VARIABLE SETUP
# -------------------------------------------------------------------
# outside (untrusted) interface / netmask and ip
oif="dc0"                 # aka natd_interface
onet="217.254.31.0"       # put your outside network here
omask="255.255.255.0"     # put your outside netmask here
outside="217.254.31.0:255.255.255.0"
oip="217.254.31.172"      # put your IP address here

# inside (trusted) interface / netmask and ip
iif="xl0"                 # internal if
inet="10.10.0.0"          # my internal network
imask="255.255.0.0"       # my internal netmask
inside="10.10.0.0:255.255.0.0"
iip="10.10.10.1"          # xl0's IP addr

# wireless (untrusted) interface / netmask and ip
wif="sis0"                # wireless if
wnet="10.30.0.0"          # wireless network
wmask="255.255.255.0"     # wireless netmask
wireless="10.30.0.0:255.255.255.0"
wip="10.30.0.1"           # sis0's IP

# ... stuff omitted for (my network) security and brevity ...

# III. INBOUND VIA WIF PRE-NAT:
# -----------------------------
#    A. Only traffic from the wireless network should arrive via
#       wif.  This stops spoofing by wireless clients.

${fwcmd} add deny all from not ${wireless} to any in via ${wif}

#    B. Wireless traffic cannot talk to the inside network

${fwcmd} add deny all from any to ${inside} in via ${wif}

#    C. Limit access to FreeBSD services for wireless clients

${fwcmd} add allow udp from any to ${wip} 53 in via ${wif}
${fwcmd} add allow tcp from any to ${wip} 8080 in via ${wif}
${fwcmd} add deny ip from any to any 25 in via ${wif}
${fwcmd} add deny ip from any to ${wip} 110 in via ${wif}
${fwcmd} add deny ip from any to ${wip} 995 in via ${wif}
${fwcmd} add deny ip from any to ${wip} 113 in via ${wif}
${fwcmd} add deny ip from any to ${wip} 139 in via ${wif}
${fwcmd} add deny ip from any to any 119 in via ${wif}

#    D. Wireless clients can't access my ISP's pages (in 
#       case they are dumb and do authentication based on 
#       "traffic from my IP addr == me"

${fwcmd} add deny ip from any to ${myisp} in via ${wif}

#    F. Allow wireless clients to exchange keys for PPTP

${fwcmd} add pass udp from ${wireless} to any 500 in recv ${wif}

#    G. Rate limit traffic from wireless clients -> Internet

${fwcmd} pipe 1 config bw 5kbytes/s
${fwcmd} add pipe 1 ip from ${wireless} to not ${wireless}

# 
# --> DYNAMIC RULES INSERTED HERE <--
#

#    H. Forward all packets from the wireless network to our 
#       wireless authorization page

${fwcmd} add 10000 fwd ${wip},8080 tcp from $wireless to any 80,8080,443 in via ${wif}

#    I. Deny anything else from wireless.

${fwcmd} add deny all from ${wireless} to any in via ${wif}

#    J. Jump past target and allow all.  When dynamic rules are
#       inserted they will jump from --> ABOVE <-- to here.

${fwcmd} add 20000 allow all from ${wireless} to any in via ${wif}

case ${natd_enable} in
[Yy][Ee][Ss])
    if [ -n "${natd_interface}" ]; then
    ${fwcmd} add divert natd all from any to any via ${natd_interface}
    fi
    ;;
esac

# ... more stuff omitted for the same reasons ...

#     C. Rate limit traffic from internet -> wireless LAN and
#        traffic from wireless -> internet

${fwcmd} pipe 2 config bw 44kbytes/s
${fwcmd} add pipe 2 ip from not ${wireless} to ${wireless}

# ...

#     Q. Everything else is denied and logged.

${fwcmd} add deny log all from any to any
   

3. The Webserver

The first step in this scheme is to divert all http traffic from wireless clients with unregistered IP addresses to a webserver running on the FreeBSD machine's inside wireless IP address that can send them the acceptable use form by means of a CGI script. I did this via a "fwd" firewall rule. In the sample rc.firewall rules above, this is the rule to which I'm referring:

ipfw add 10000 fwd ${wip},8080 tcp from $wireless to any 80 in via ${wif}
   

When a packet comes in from the wireless subnet via the wireless interface and is destined for www.whatever.com port 80 it will be forwarded to the FreeBSD machine's wireless IP address port 8080. This section of the ipfw(8) ipfw(8) manpage about forwarding is vital:

"The fwd action does not change the contents of the packet at all. In particular, the destination address remains unmodified, so packets forwarded to another system will usually be rejected by that system unless there is a matching rule on that system to capture them. For packets forwarded locally, the local address of the socket will be set to the original destination address of the packet. This makes the netstat(1) netstat(1) entry look rather weird but is intended for use with transparent proxy servers."

In other words, the webserver at port 8080 on my system is going to be getting TCP/IP packets that look like they are to www.whatever.com. Moreover the HTTP requests in those packets are going to be trying to GET the original path of the request. Clearly this webserver has to be able to handle crazy URLs without sending back a 404 File not found.

I'm not an apache guru so maybe what I'm about to tell you is wrong. That said, I could not get my stock http server to do this stuff. Instead I made a new instance of apache just to listen on port 8080 of the wireless IP address and handle the crazy wireless URLs. I include the full httpd.conf file in a tarball at the end of this article but the important part is this:

AliasMatch .* /usr/local/www/data/wireless/notice.cgi
   

The apache manual on mod_alias describes the AliasMatch directive as follows:

"This directive is equivalent to Alias, but makes use of standard regular expressions, instead of simple prefix matching. The supplied regular expression is matched against the URL-path, and if it matches, the server will substitute any parenthesized matches into the given string and use it as a filename. For example, to activate the /icons directory, one might use:

    AliasMatch ^/icons(.*) /usr/local/apache/icons$1"
   

My regular expression, .*, matches anything and directs it to notice.cgi, a little perl script. Since scripts are enabled by extension on this server the CGI script runs and instead of www.whatever.com the unregistered wireless user gets my portal page.


4. The CGI Script

The notice.cgi script has two jobs: to display a page full of acceptable use mumbo-jumbo and to handle when people accept it.

The first job is simple, it just throws some HTML out. People probably were surfing to www.whatever.com so they don't expect this page. It's friendly, tells them what's up, and gives them a chance to accept my policy.

Then, the same notice.cgi script has to handle the acceptance because the wireless client's IP is not unblocked yet -- whatever URL the submit goes to will get redirected to the apache server and matched by the AliasMatch rule... and end up back at the script anyway.

When they accept the policy the script says thanks and adds their IP address to the list that are to be unblocked. A separate daemon process handles unblocking the wireless IP address by modifying the firewall rules. Specifically it adds a rule to bypass the "forward http to the webserver" and "deny everything else" entries in the ruleset. I use a the daemon process because I want to be able to re-block the IP address after some amount of time has elapsed. Since the CGI script is only invoked as a response to to a web hit it is not able to re-block an IP after a timeout.

The last thing the CGI script does is redirect the wireless user to the original website to which they surfed.

The CGI script is included here for your reading pleasure but is also included again (with support HTML files) in the tarball at the end of this article.

#!/usr/bin/perl

use CGI qw(:standard);
use MLDBM::Sync;                    
use MLDBM qw(DB_File Storable);      
use MLDBM qw(MLDBM::Sync::SDBM_File); 
use Fcntl qw(:DEFAULT);            

$file = "/var/auth";
if (param('auth') eq "YES")
{
    &AuthIp($ENV{REMOTE_ADDR});
    &Accepted;
}
else
{
    &Must_Accept;
}
exit(0);


sub Must_Accept
{
    if (!open(FILE, "index.html"))
    {
        print "Content-type:text/html\n\n";
        print "Page is broken, come back later. :(\n";
    }
    else
    {
        print "Content-type:text/html\n\n";
        while(<FILE>)
        {
            print;
        }
        close(FILE);
        print "<A href=" . url() . "?auth=YES>I ACCEPT THESE TERMS</a>";
        print "</CENTER><BR><P></BODY></HTML>\n";
    }
}


sub Accepted
{
    $foo = url();
    $foo =~ s/\?auth=YES$//;
    print "Content-type: text/html\n\n";
    print "<HTML><HEAD>\n";
    print "<META HTTP-EQUIV=\"Refresh\" CONTENT=\"5;URL=$foo\">";

    if (!open(FILE, "accepted.html"))
    {
        print "Thanks, sorry this is broken a little...\n";
    }
    else
    {
        while(<FILE>)
        {
            print;
        }
        close(FILE);
    }
}

sub AuthIp 
{
   my ($ip) = @_;
   my %cache;
   my $sync_dbm_obj = tie (%cache,'MLDBM::Sync',$file,O_CREAT|O_RDWR,0644);
   $sync_dbm_obj->Lock;
   my $time = time();
   $cache{$ip} = $time;
   $sync_dbm_obj->UnLock;
   untie (%cache);
}
   

5. The Daemon

Once the CGI script has decided to unblock an IP address, that IP is placed in a DBM file that is tied to a hash array in a perl script. This perl script is a daemon process in that it is always running in the background on the FreeBSD machine. Every few seconds it wakes up and...:

  • Sees if there are any new IP addresses to unblock.

  • Sees if any of the unblocked IP addresses have an expired time limit and must be reblocked.

The script (which I called wireless-auth) is listed in this section but is also included in the constantly-referred-to tarball at the end of the article. The rules it adds to unblock a new wireless client's IP address look like this:

ipfw add $start_range set 1 skipto 20000 ip from $ip to any in via ${wif}
   

Rules like this are part of one ruleset (set 1) so that they can be deleted easily in one ipfw command when the firewall is being rebuilt. All that they do is allow one particular wireless IP address to skip around this part of the firewall rules:

    # ---> THE RULES SKIP FROM HERE ...

    #    I. Forward all packets from the wireless network to our 
    #       wireless authorization page

    ${fwcmd} add 10000 fwd ${wip},8080 tcp from $wireless to any 80,8080,443 in via ${wif}

    #    J. Deny anything else from wireless.

    ${fwcmd} add deny all from ${wireless} to any in via ${wif}

    # ---> TO HERE

    ${fwcmd} add 20000 allow all from ${wireless} to any in via ${wif}

And here's the daemon itself:

#!/usr/bin/perl

use MLDBM::Sync;
use MLDBM qw(DB_File Storable);
use MLDBM qw(MLDBM::Sync::SDBM_File);
use Fcntl qw(:DEFAULT);
use Config;

my $prefix = "10.0.30";     # wireless net prefix
my $gw = "10.0.30.1";       # my wireless IP addr
my $timeout = 60 * 60;      # firewall opening timeout
my $file = "/var/auth";     # IPC file
$ipfw = "/sbin/ipfw";       # path to ipfw
my %current;
$verbose = 1;

open(LOG, ">/var/log/wireless_auth.log") || die $!;

&gen_firewall;
while (true) 
{
    my %ips;

    my $time = time();
    $time = $time - $timeout;

    my %cache;

    my $sync_dbm_obj = tie (%cache,'MLDBM::Sync',$file,O_CREAT|O_RDWR,0666);
    $sync_dbm_obj->Lock;

    #
    # Create a cache hash based on the IPs in the file at this time
    #
    $cache_hash = 0;
    foreach my $ip (keys %cache) 
    {
        if ($cache{$ip} < $time) 
        {
            delete ($cache{$ip});
            print LOG "$ip has expired.\n" if ($verbose);
        } 
        else 
        { 
            if ($ip =~ /\d+\.\d+\.\d+\.(\d+)/)
            {
                $cache_hash ^= $1;
            }
            $ips{$ip} = 1;
        }
    }

    #
    # Create a current hash based on the rules in the firewall at this
    # time.
    #
    $current_hash = 0;
    if (open(IPFW, "$ipfw show |"))
    {
        while(<IPFW>)
        {
            if (/\d+\s+\d+\s+\d+\s+skipto\s+20000\s+ip\s+from\s+\d+\.\d+\.\d+\.(\d+)\s/)
            {
                $current_hash ^= $1;
            }
        }
        close(IPFW);
    }
    else
    {
        foreach my $ip (keys %current)
        {
            if ($ip =~ /\d+\.\d+\.\d+\.(\d+)/)
            {
                $current_hash ^= $1;
            }
        }
    }

    if ($current_hash != $cache_hash)
    {
        #
        # Rebuild the firewall...
        #
        print LOG "Detected a change on the firewall...\n" if ($verbose);
        gen_firewall(keys %ips);

        #
        # Update current
        #
        foreach my $key (keys %current) 
        {
            delete $current{$key};
        }
        foreach my $key (keys %ips) 
        {
            $current{$key} = 1;
        }
    }
    $sync_dbm_obj->UnLock;
    untie(%cache);
    sleep (5);
}


sub gen_firewall 
{
    my (@ips) = @_;
    my $start_range = 9000;
    my $date = `date`;

    system "$ipfw -q delete set 1";
    foreach my $ip (@ips) 
    {
        print LOG "$date: $ip is authorized.\n" if ($verbose);
        $ips{$ip} = 1;
        system "$ipfw -q add $start_range set 1 skipto 20000 ip from $ip to any in via sis0";
        $start_range++;
    }
}
   

6. Bandwidth Limiting

You can use the ipfw to limit the amount of bandwidth that your wireless clients can use by setting up some pipes. Here's an example:

    pipe 1 config bw 5kbytes/s
    add pipe 1 ip from ${wireless} to not ${wireless}

    pipe 2 config bw 44kbytes/s
    add pipe 2 ip from not ${wireless} to ${wireless}

These rules limit your wireless client's outgoing bandwidth to 5k/sec and incoming bandwidth to 44k/sec. They get reasonable performance that doesn't ever swamp my internal subnet.


7. Miscellaneous

I have to give a plug to my ISP because it's a rare thing in this age of fascist service providers to find one with a liberal and progressive policy towards bandwidth sharing. I've also found Speakeasy to be pretty responsive and customer oriented when I've had to deal with them.

Part of the deal with bandwidth sharing is that I am responsible for what happens from my IP address. So that means that part of the acceptable use policy I serve out with that CGI script is copied from the policy that I have agreed to abide by. In addition I added some stuff I dug out of a tool called OpenSplash that seems to have come from a wireless acceptable use policy in San Francisco.

And, of course, here is the promised tarball that contains all these sample files along with this document in various forms.


8. Other Tools

I didn't figure this out on my own; in fact large parts of the daemon are lifted from some other tools I found that do captive portals. Some of these other packages only work on Linux. Some of them are for FreeBSD too. In my opinion, all of them purport to be "something you install that makes it work" rather than what I tried to do with this article (namely, tell you how it works and give you samples that you can custom fit to your own system). Nonetheless, you might want to google NoCatAuth, WiCap and OpenSplash. And, of course, thank you to the authors of those tools.


index.html was last updated 19 July 2013 and is Copyright (C) 2002-2015 by Scott Gasch (scott.gasch@gmail.com).