WiFi Captive Portal

A captive portal is a piece of software that prompts for user interaction before allowing the client to access the internet or other resources on the network. It is a combination of a firewall and a webserver. In this tutorial, I will explain how to create an open WiFi network. Before deploying an open WiFi network, you may want to consult a lawyer of the legality and restrictions for having one. You can also review what has been said by lawyers here and here.

When a device first connects to any network, it sends out a HTTP request and expects an HTTP status code of 200. If the device receives a HTTP 200 status code, it assumes it has unlimited internet access. Captive portal prompts are displayed when you are able to manipulate this first HTTP message to return a HTTP status code of 302 (redirect) to the captive portal of your choice.

To set up a captive portal on a Raspberry Pi, you will need a wired network (I will refer to this as WAN or uplink) and a wireless network (such as the Ralink RT5372 or the Ralink RT5370) which you can set into AP mode. A server with 2 NICs would also suffice if you want to perform this on a wired LAN instead.

 

UPDATE 2/15/2017: If you get the too many redirects error, look at the hotspot.localnet nginx configuration. It could be that the dollar signs $ are not present. The below block is what the location / block should look like in the hotspot.localnet virtual host.

     location / {
         try_files $uri $uri/ index.php;
     }

Operating System

The operating system assumed will be Rapsbian Jessie. You can also follow this tutorial with a Debian server with two NICs – as long as one is dedicated to the WAN and the other to the LAN. It is also possible to use another Linux Distribution, but converting the commands will be up to you. In a later article, I will describe how to perform the steps on OpenWRT.

Required Packages

sudo apt-get install iptables-persistent conntrack dnsmasq nginx php5 php5-common php5-fpm hostapd

Configuring dnsmasq

You can certainly use other DNS and DHCP servers, but I like to use dnsmasq as it is lightweight and found on many routers already. This indicates to me that dnsmasq is pretty stable and able to perform in the enterprise.

The contents of my dnsmasq.conf are as follows:

# Never forward addresses in the non-routed address spaces.
bogus-priv

# Add other name servers here, with domain specs if they are for
# non-public domains.
server=/localnet/192.168.24.1

# Add local-only domains here, queries in these domains are answered
# from /etc/hosts or DHCP only.
local=/localnet/

# If you want dnsmasq to listen for DHCP and DNS requests only on
# specified interfaces (and the loopback) give the name of the
# interface (eg eth0) here.
# Repeat the line for more than one interface.
interface=wlan0

# Set the domain for dnsmasq. this is optional, but if it is set, it
# does the following things.
# 1) Allows DHCP hosts to have fully qualified domain names, as long
#     as the domain part matches this setting.
# 2) Sets the "domain" DHCP option thereby potentially setting the
#    domain of all systems configured by DHCP
# 3) Provides the domain part for "expand-hosts"
domain=localnet

# Uncomment this to enable the integrated DHCP server, you need
# to supply the range of addresses available for lease and optionally
# a lease time. If you have more than one network, you will need to
# repeat this for each network on which you want to supply DHCP
# service.
dhcp-range=192.168.24.50,192.168.24.250,2h

# Override the default route supplied by dnsmasq, which assumes the
# router is the same machine as the one running dnsmasq.
dhcp-option=3,192.168.24.1

#DNS Server
dhcp-option=6,192.168.24.1

# Set the DHCP server to authoritative mode. In this mode it will barge in
# and take over the lease for any client which broadcasts on the network,
# whether it has a record of the lease or not. This avoids long timeouts
# when a machine wakes up on a new network. DO NOT enable this if there's
# the slightest chance that you might end up accidentally configuring a DHCP
# server for your campus/company accidentally. The ISC server uses
# the same option, and this URL provides more information:
# http://www.isc.org/files/auth.html
dhcp-authoritative

dnsmasq also reads in the /etc/hosts file and adds that information into the dns. Since we want to use pretty hostnames in our captive portal urls, we need to add a few entries to our hosts file.

# Contents of /etc/hosts
127.0.0.1	localhost 
192.168.24.1	hotspot.localnet
::1		localhost ip6-localhost ip6-loopback
fe00::0		ip6-localnet
ff00::0		ip6-mcastprefix
ff02::1		ip6-allnodes
ff02::2		ip6-allrouters

Network

Now that we have DNS and DHCP set up, we need to set up the interfaces. Edit /etc/network/interfaces and place in the following information:

# The loopback network interface
auto lo eth0
iface lo inet loopback

# The "wan" network interface
iface eth0 inet dhcp

# The "lan" network interface
iface wlan0 inet static
  address 192.168.24.1
  netmask 255.255.255.0

We also need to tell the kernel to forward IP packets between interfaces. To do that, enter the following command

echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf

You will need to reboot in order for that change to take effect, but do not do that quite yet.

Hostapd

hostapd will be the service that sets up the SSID and ensures it is broadcasting over the wireless card. If you are setting up the captive portal on a server with 2 NICs you can skip to the next section.

While you may certainly set up hostapd in WPA2 mode, I am assuming that because we are setting up a captive portal, this will be our authentication method rather than the encryption setting on the access point. To set up an open WiFi network, edit /etc/hostapd/hostapd.conf with the following information

interface=wlan0
ssid=MyOpenAP # name of the WiFi access point
hw_mode=g
channel=6 #use 1, 6, or 11
auth_algs=1
wmm_enabled=0

You will now have to set up the daemon by editing the contents of /etc/default/hostapd and change the line:

#DAEMON_CONF=""

to:

DAEMON_CONF="/etc/hostapd/hostapd.conf"

Iptables

iptables is our firewall. This is how we are going to block all connections that we have not authorized. Below are the commands to set up the firewall:

# Turn into root
sudo -i
# Flush all connections in the firewall
iptables -F
# Delete all chains in iptables
iptables -X
# wlan0 is our wireless card. Replace with your second NIC if doing it from a server.
# This will set up our structure
iptables -t mangle -N wlan0_Trusted
iptables -t mangle -N wlan0_Outgoing
iptables -t mangle -N wlan0_Incoming
iptables -t mangle -I PREROUTING 1 -i wlan0 -j wlan0_Outgoing
iptables -t mangle -I PREROUTING 1 -i wlan0 -j wlan0_Trusted
iptables -t mangle -I POSTROUTING 1 -o wlan0 -j wlan0_Incoming
iptables -t nat -N wlan0_Outgoing
iptables -t nat -N wlan0_Router
iptables -t nat -N wlan0_Internet
iptables -t nat -N wlan0_Global
iptables -t nat -N wlan0_Unknown
iptables -t nat -N wlan0_AuthServers
iptables -t nat -N wlan0_temp
iptables -t nat -A PREROUTING -i wlan0 -j wlan0_Outgoing
iptables -t nat -A wlan0_Outgoing -d 192.168.24.1 -j wlan0_Router
iptables -t nat -A wlan0_Router -j ACCEPT
iptables -t nat -A wlan0_Outgoing -j wlan0_Internet
iptables -t nat -A wlan0_Internet -m mark --mark 0x2 -j ACCEPT
iptables -t nat -A wlan0_Internet -j wlan0_Unknown
iptables -t nat -A wlan0_Unknown -j wlan0_AuthServers
iptables -t nat -A wlan0_Unknown -j wlan0_Global
iptables -t nat -A wlan0_Unknown -j wlan0_temp
# forward new requests to this destination
iptables -t nat -A wlan0_Unknown -p tcp --dport 80 -j DNAT --to-destination 192.168.24.1
iptables -t filter -N wlan0_Internet
iptables -t filter -N wlan0_AuthServers
iptables -t filter -N wlan0_Global
iptables -t filter -N wlan0_temp
iptables -t filter -N wlan0_Known
iptables -t filter -N wlan0_Unknown
iptables -t filter -I FORWARD -i wlan0 -j wlan0_Internet
iptables -t filter -A wlan0_Internet -m state --state INVALID -j DROP
iptables -t filter -A wlan0_Internet -o eth0 -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
iptables -t filter -A wlan0_Internet -j wlan0_AuthServers
iptables -t filter -A wlan0_AuthServers -d 192.168.24.1 -j ACCEPT
iptables -t filter -A wlan0_Internet -j wlan0_Global
# allow access to my website :)
iptables -t filter -A wlan0_Global -d andrewwippler.com -j ACCEPT
#allow unrestricted access to packets marked with 0x2
iptables -t filter -A wlan0_Internet -m mark --mark 0x2 -j wlan0_Known
iptables -t filter -A wlan0_Known -d 0.0.0.0/0 -j ACCEPT
iptables -t filter -A wlan0_Internet -j wlan0_Unknown
# allow access to DNS and DHCP
# This helps power users who have set their own DNS servers
iptables -t filter -A wlan0_Unknown -d 0.0.0.0/0 -p udp --dport 53 -j ACCEPT
iptables -t filter -A wlan0_Unknown -d 0.0.0.0/0 -p tcp --dport 53 -j ACCEPT
iptables -t filter -A wlan0_Unknown -d 0.0.0.0/0 -p udp --dport 67 -j ACCEPT
iptables -t filter -A wlan0_Unknown -d 0.0.0.0/0 -p tcp --dport 67 -j ACCEPT
iptables -t filter -A wlan0_Unknown -j REJECT --reject-with icmp-port-unreachable
#allow forwarding of requests from anywhere to eth0/WAN
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE

#save our iptables
iptables-save > /etc/iptables/rules.v4

nginx

Now we need to set up nginx to send a magic packet that prompts the user for action (i.e MyOpenAP requires you to sign in). To do that, we will run the following commands:

# Make the HTML Document Root
mkdir /usr/share/nginx/html/portal
chown nginx:www-data /usr/share/nginx/html/portal
chmod 755 /usr/share/nginx/html/portal

# create the nginx hotspot.conf file
cat << EOF > /etc/nginx/sites-available/hotspot.conf
server {
    # Listening on IP Address.
    # This is the website iptables redirects to
    listen       80 default_server;
    root         /usr/share/nginx/html/portal;

    # For iOS
    if ($http_user_agent ~* (CaptiveNetworkSupport) ) {
        return 302 http://hotspot.localnet/hotspot.html;
    }

    # For others
    location / {
        return 302 http://hotspot.localnet/;
    }
 }

 upstream php {
    #this should match value of "listen" directive in php-fpm pool
		server unix:/tmp/php-fpm.sock;
		server 127.0.0.1:9000;
	}

server {
     listen       80;
     server_name  hotspot.localnet;
     root         /usr/share/nginx/html/portal;

     location / {
         try_files $uri $uri/ index.php;
     }

    # Pass all .php files onto a php-fpm/php-fcgi server.
    location ~ [^/]\.php(/|$) {
    	fastcgi_split_path_info ^(.+?\.php)(/.*)$;
    	if (!-f $document_root$fastcgi_script_name) {
    		return 404;
    	}
    	# This is a robust solution for path info security issue and works with "cgi.fix_pathinfo = 1" in /etc/php.ini (default)
    	include fastcgi_params;
    	fastcgi_index index.php;
    	fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    	fastcgi_pass php;
    }
}

EOF

# Enable the website and reload nginx
ln -s /etc/nginx/sites-available/hotspot.conf /etc/nginx/sites-enabled/hotspot.conf
systemctl reload nginx

We have just set up the path /usr/share/nginx/html/portal to serve web pages if accessed via IP (note the default_server directive) as well as if accessed by the hostname hotspot.localnet. If accessed via IP, it will trigger a 302 redirect to the hostname hotspot.localnet.

HTML / PHP

When it comes to the coding aspect, we only want to allow access if the webserver has determined the user has clicked the I agree to the terms button. To do that, I will add a <input type="hidden" name="security_code" value="andrew-wippler-is-cool" /> to my form field. This will stop a few script kiddies from gaining access without clicking the button, but one may want to add additional security features if deploying this in the enterprise (or just buy an appliance that has a captive portal already).

We will need two files added to /usr/share/nginx/html/portal. They are the following:

index.php
<!DOCTYPE html>
<?php

// Grant access if the Security code is accurate.
if ($_POST['security_code'] == "andrew-wippler-is-cool") {

// Grab the MAC address
$arp = "/usr/sbin/arp"; // Attempt to get the client's mac address
$mac = shell_exec("$arp -a ".$_SERVER['REMOTE_ADDR']);
preg_match('/..:..:..:..:..:../',$mac , $matches);
$mac2 = $matches[0];

// Reconnect the device to the firewall
exec("sudo rmtrack " . $_SERVER['REMOTE_ADDR']);
$i = "sudo iptables -t mangle -A wlan0_Outgoing  -m mac --mac-source ".$_GET['mac']." -j MARK --set-mark 2";
exec($i);

sleep(5);

?> <html>
<head>
<title></title>
</head>
<body>
<h1>You are now free to browse the internet.</h1>
</body> </html>
<?php } else {
  // this is what is seen when first viewing the page
  ?>
  <html>
  <head>
  <title></title>
  </head>
  <body>
  <h1>Authorization Required</h1>
  <p>Before continuing, you must first agree to the <a href="#">Terms of Service</a> and be of the legal age to do that in your selective country or have Parental Consent.
  </p>
  <form method="post" action="index.php">
    <input type="hidden" name="security_code" value="andrew-wippler-is-cool" />
    <input type="checkbox" name="checkbox1" CHECKED /><label for="checkbox1">I Agree to the terms</label><br />
    <input type="submit" value="Connect" />
  </form>
  </body> </html>
<?php } ?>
hotspot.html
 <!--
 <?xml version="1.0" encoding="UTF-8"?>
 <WISPAccessGatewayParam xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://www.wballiance.net/wispr_2_0.xsd">
 <Redirect>
 <MessageType>100</MessageType>
 <ResponseCode>0</ResponseCode>
 <VersionHigh>2.0</VersionHigh>
 <VersionLow>1.0</VersionLow>
 <AccessProcedure>1.0</AccessProcedure>
 <AccessLocation>Andrew Wippler is awesome</AccessLocation>
 <LocationName>MyOpenAP</LocationName>
 <LoginURL>http://hotspot.localnet/</LoginURL>
 </Redirect>
 </WISPAccessGatewayParam>
 -->

Additional Configurations

We are almost done; however, you may have noticed the line 14 in index.php which includes executing rmtrack. What is rmtrack? It is the code of Andy Beverley which is licensed under the GNU Free Documentation License 1.3. In Andy’s tutorial for a captive portal, rmtrack is used to remove the connection information in the kernel to prevent login loops. Let’s create that file now:

cat << EOF > /usr/bin/rmtrack
/usr/sbin/conntrack -L \
    |grep $1 \
    |grep ESTAB \
    |grep 'dport=80' \
    |awk \
        "{ system(\"conntrack -D --orig-src $1 --orig-dst \" \
            substr(\$6,5) \" -p tcp --orig-port-src \" substr(\$7,7) \" \
            --orig-port-dst 80\"); }"
EOF

chmod +x /usr/bin/rmtrack

We also have to allow our web server user www-data access to run privileged commands. We can do that by typing visudo and appending the below lines to the file.

www-data ALL = NOPASSWD: /usr/bin/rmtrack [0-9]*.[0-9]*.[0-9]*.[0-9]*
www-data ALL = NOPASSWD: /sbin/iptables -t mangle -A wlan0_Outgoing  -m mac --mac-source ??\:??\:??\:??\:??\:?? -j MARK --set-mark 2

With the sudoers file saved, you are now ready to reboot the Raspberry Pi and enjoy offering a fairly simplistic captive portal.

64 comments

  1. Nice Article and its working like a charm for me. However, I need one help on the Iptable rules.

    I was trying to redirect all the traffic packets marked with 2 to my transparent proxy. Any pointers would be greatly appreciated.

  2. So how does this setup change if you are using raspbian stretch? the /etc/network/interfaces is pretty much a blank file if you use stretch. Thanks!

  3. hi, I’ve read the whole article. What am i not been able to understand what exactly causes the browser popup? Is it nginx? Is it PHP or what?

  4. Bro after i followed your tutorial. when i connect my phone to the hotspot, yes the captive portal showed up, but after i click the sign in, i still can’t connect to the internet, even though the ‘you can now connect to internet’ showed

  5. Let’s say our access point IP is 192.168.45.1.
    Now a client is connected to the AP.
    What part in the article is responsible of answering requests even if they were sent to 192.168.45.100 for example, or if the client is trying to do DNS query, to ‘lie’ to him and to answer with the captive IP (192.168.45.1)?

  6. Hi Andrew,
    When I try to open a website, I get redirected to my captive portal page but address bar of browser shows that website’s name only.
    Is there a way to change that too ?

    thanks in advance!

  7. Hi Andrew, this is awesome! I tried it on my RPi3 (built-in wlan0), works great only if the RPi is pluged to eth0 i.e has internet access.
    The CaptivePortal popup doesnt show-up if the RPi has no internet access. I need to use the RPi to communicate/inform people in remote areas. Please help.

    1. Hey OrcsRiver and all did you find solution to this issue?

    2. hi OrcsRiver, i’m trying to do the same thing on a RPi3 and zeroW. did u find solution to fix it?
      I find an alternative that didn’t work on all smartphones

  8. It is giving me error on reloading nginx. the error is “invalid host in upstream 127.0.0.1/9000.

  9. Hey Guys,
    sombody can tell me how to route my Squid3 when the client mark to access to all?

    Currenly I used /sbin/iptables -t nat -A PREROUTING -p tcp -s 10.1.0.0/24 –dport 80 -j REDIRECT –to-port 3128 but with portal it doesn’t work

    1. I figured it how to route & include my port forwarding stuff to have more control about whitelisted ports. But someone could tell how to remove the marked devices like do a Time Conditions?

Comments are closed.