{"id":162,"date":"2016-03-11T06:00:06","date_gmt":"2016-03-11T14:00:06","guid":{"rendered":"http:\/\/andrewwippler.com\/?p=162"},"modified":"2017-03-26T10:50:14","modified_gmt":"2017-03-26T18:50:14","slug":"wifi-captive-portal","status":"publish","type":"post","link":"https:\/\/andrewwippler.com\/2016\/03\/11\/wifi-captive-portal\/","title":{"rendered":"WiFi Captive Portal"},"content":{"rendered":"

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<\/a> and here<\/a>.<\/p>\n

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.<\/p>\n

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<\/a> or the Ralink RT5370<\/a>) 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.<\/p>\n

 <\/p>\n

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

     location \/ {\r\n         try_files $uri $uri\/ index.php;\r\n     }<\/code><\/pre>\n

<\/p>\n

Operating System<\/h3>\n

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.<\/p>\n

Required Packages<\/h3>\n
sudo apt-get install iptables-persistent conntrack dnsmasq nginx php5 php5-common php5-fpm hostapd<\/code><\/pre>\n

Configuring dnsmasq<\/h3>\n

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

The contents of my dnsmasq.conf<\/code> are as follows:<\/p>\n

# Never forward addresses in the non-routed address spaces.\r\nbogus-priv\r\n\r\n# Add other name servers here, with domain specs if they are for\r\n# non-public domains.\r\nserver=\/localnet\/192.168.24.1\r\n\r\n# Add local-only domains here, queries in these domains are answered\r\n# from \/etc\/hosts or DHCP only.\r\nlocal=\/localnet\/\r\n\r\n# If you want dnsmasq to listen for DHCP and DNS requests only on\r\n# specified interfaces (and the loopback) give the name of the\r\n# interface (eg eth0) here.\r\n# Repeat the line for more than one interface.\r\ninterface=wlan0\r\n\r\n# Set the domain for dnsmasq. this is optional, but if it is set, it\r\n# does the following things.\r\n# 1) Allows DHCP hosts to have fully qualified domain names, as long\r\n#     as the domain part matches this setting.\r\n# 2) Sets the \"domain\" DHCP option thereby potentially setting the\r\n#    domain of all systems configured by DHCP\r\n# 3) Provides the domain part for \"expand-hosts\"\r\ndomain=localnet\r\n\r\n# Uncomment this to enable the integrated DHCP server, you need\r\n# to supply the range of addresses available for lease and optionally\r\n# a lease time. If you have more than one network, you will need to\r\n# repeat this for each network on which you want to supply DHCP\r\n# service.\r\ndhcp-range=192.168.24.50,192.168.24.250,2h\r\n\r\n# Override the default route supplied by dnsmasq, which assumes the\r\n# router is the same machine as the one running dnsmasq.\r\ndhcp-option=3,192.168.24.1\r\n\r\n#DNS Server\r\ndhcp-option=6,192.168.24.1\r\n\r\n# Set the DHCP server to authoritative mode. In this mode it will barge in\r\n# and take over the lease for any client which broadcasts on the network,\r\n# whether it has a record of the lease or not. This avoids long timeouts\r\n# when a machine wakes up on a new network. DO NOT enable this if there's\r\n# the slightest chance that you might end up accidentally configuring a DHCP\r\n# server for your campus\/company accidentally. The ISC server uses\r\n# the same option, and this URL provides more information:\r\n# http:\/\/www.isc.org\/files\/auth.html\r\ndhcp-authoritative\r\n<\/code><\/pre>\n

dnsmasq<\/code> also reads in the \/etc\/hosts<\/code> 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.<\/p>\n

# Contents of \/etc\/hosts\r\n127.0.0.1\tlocalhost \r\n192.168.24.1\thotspot.localnet\r\n::1\t\tlocalhost ip6-localhost ip6-loopback\r\nfe00::0\t\tip6-localnet\r\nff00::0\t\tip6-mcastprefix\r\nff02::1\t\tip6-allnodes\r\nff02::2\t\tip6-allrouters<\/code><\/pre>\n

Network<\/h3>\n

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

# The loopback network interface\r\nauto lo eth0\r\niface lo inet loopback\r\n\r\n# The \"wan\" network interface\r\niface eth0 inet dhcp\r\n\r\n# The \"lan\" network interface\r\niface wlan0 inet static\r\n  address 192.168.24.1\r\n  netmask 255.255.255.0<\/code><\/pre>\n

We also need to tell the kernel to forward IP packets between interfaces. To do that, enter the following command<\/p>\n

echo \"net.ipv4.ip_forward=1\" >> \/etc\/sysctl.conf<\/code><\/pre>\n

You will need to reboot in order for that change to take effect, but do not do that quite yet.<\/p>\n

Hostapd<\/h3>\n

hostapd<\/code> 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.<\/p>\n

While you may certainly set up hostapd<\/code> 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<\/code> with the following information<\/p>\n

interface=wlan0\r\nssid=MyOpenAP # name of the WiFi access point\r\nhw_mode=g\r\nchannel=6 #use 1, 6, or 11\r\nauth_algs=1\r\nwmm_enabled=0<\/code><\/pre>\n

You will now have to set up the daemon by editing the contents of \/etc\/default\/hostapd<\/code> and change the line:<\/p>\n

#DAEMON_CONF=\"\"<\/code><\/pre>\n

to:<\/p>\n

DAEMON_CONF=\"\/etc\/hostapd\/hostapd.conf\"<\/code><\/pre>\n

Iptables<\/h3>\n

iptables<\/code> 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:<\/p>\n

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

nginx<\/h3>\n

Now we need to set up nginx<\/code> 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:<\/p>\n

# Make the HTML Document Root\r\nmkdir \/usr\/share\/nginx\/html\/portal\r\nchown nginx:www-data \/usr\/share\/nginx\/html\/portal\r\nchmod 755 \/usr\/share\/nginx\/html\/portal\r\n\r\n# create the nginx hotspot.conf file\r\ncat << EOF > \/etc\/nginx\/sites-available\/hotspot.conf\r\nserver {\r\n    # Listening on IP Address.\r\n    # This is the website iptables redirects to\r\n    listen       80 default_server;\r\n    root         \/usr\/share\/nginx\/html\/portal;\r\n\r\n    # For iOS\r\n    if ($http_user_agent ~* (CaptiveNetworkSupport) ) {\r\n        return 302 http:\/\/hotspot.localnet\/hotspot.html;\r\n    }\r\n\r\n    # For others\r\n    location \/ {\r\n        return 302 http:\/\/hotspot.localnet\/;\r\n    }\r\n }\r\n\r\n upstream php {\r\n    #this should match value of \"listen\" directive in php-fpm pool\r\n\t\tserver unix:\/tmp\/php-fpm.sock;\r\n\t\tserver 127.0.0.1:9000;\r\n\t}\r\n\r\nserver {\r\n     listen       80;\r\n     server_name  hotspot.localnet;\r\n     root         \/usr\/share\/nginx\/html\/portal;\r\n\r\n     location \/ {\r\n         try_files $uri $uri\/ index.php;\r\n     }\r\n\r\n    # Pass all .php files onto a php-fpm\/php-fcgi server.\r\n    location ~ [^\/]\\.php(\/|$) {\r\n    \tfastcgi_split_path_info ^(.+?\\.php)(\/.*)$;\r\n    \tif (!-f $document_root$fastcgi_script_name) {\r\n    \t\treturn 404;\r\n    \t}\r\n    \t# This is a robust solution for path info security issue and works with \"cgi.fix_pathinfo = 1\" in \/etc\/php.ini (default)\r\n    \tinclude fastcgi_params;\r\n    \tfastcgi_index index.php;\r\n    \tfastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;\r\n    \tfastcgi_pass php;\r\n    }\r\n}\r\n\r\nEOF\r\n\r\n# Enable the website and reload nginx\r\nln -s \/etc\/nginx\/sites-available\/hotspot.conf \/etc\/nginx\/sites-enabled\/hotspot.conf\r\nsystemctl reload nginx<\/code><\/pre>\n

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

HTML \/ PHP<\/h3>\n

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<\/strong> button. To do that, I will add a <input type=\"hidden\" name=\"security_code\" value=\"andrew-wippler-is-cool\" \/><\/code> 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).<\/p>\n

We will need two files added to \/usr\/share\/nginx\/html\/portal<\/code>. They are the following:<\/p>\n

index.php<\/h6>\n
<!DOCTYPE html>\r\n<?php\r\n\r\n\/\/ Grant access if the Security code is accurate.\r\nif ($_POST['security_code'] == \"andrew-wippler-is-cool\") {\r\n\r\n\/\/ Grab the MAC address\r\n$arp = \"\/usr\/sbin\/arp\"; \/\/ Attempt to get the client's mac address\r\n$mac = shell_exec(\"$arp -a \".$_SERVER['REMOTE_ADDR']);\r\npreg_match('\/..:..:..:..:..:..\/',$mac , $matches);\r\n$mac2 = $matches[0];\r\n\r\n\/\/ Reconnect the device to the firewall\r\nexec(\"sudo rmtrack \" . $_SERVER['REMOTE_ADDR']);\r\n$i = \"sudo iptables -t mangle -A wlan0_Outgoing\u00a0 -m mac --mac-source \".$_GET['mac'].\" -j MARK --set-mark 2\";\r\nexec($i);\r\n\r\nsleep(5);\r\n\r\n?> <html>\r\n<head>\r\n<title><\/title>\r\n<\/head>\r\n<body>\r\n<h1>You are now free to browse the internet.<\/h1>\r\n<\/body> <\/html>\r\n<?php } else {\r\n\u00a0 \/\/ this is what is seen when first viewing the page\r\n\u00a0 ?>\r\n\u00a0 <html>\r\n\u00a0 <head>\r\n\u00a0 <title><\/title>\r\n\u00a0 <\/head>\r\n\u00a0 <body>\r\n\u00a0 <h1>Authorization Required<\/h1>\r\n\u00a0 <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.\r\n\u00a0 <\/p>\r\n\u00a0 <form method=\"post\" action=\"index.php\">\r\n\u00a0\u00a0\u00a0 <input type=\"hidden\" name=\"security_code\" value=\"andrew-wippler-is-cool\" \/>\r\n\u00a0\u00a0\u00a0 <input type=\"checkbox\" name=\"checkbox1\" CHECKED \/><label for=\"checkbox1\">I Agree to the terms<\/label><br \/>\r\n\u00a0\u00a0\u00a0 <input type=\"submit\" value=\"Connect\" \/>\r\n\u00a0 <\/form>\r\n\u00a0 <\/body> <\/html>\r\n<?php } ?>\r\n<\/code><\/pre>\n
hotspot.html<\/h6>\n
 <!--\r\n\u00a0<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n\u00a0<WISPAccessGatewayParam xmlns:xsi=\"http:\/\/www.w3.org\/2001\/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http:\/\/www.wballiance.net\/wispr_2_0.xsd\">\r\n\u00a0<Redirect>\r\n\u00a0<MessageType>100<\/MessageType>\r\n\u00a0<ResponseCode>0<\/ResponseCode>\r\n\u00a0<VersionHigh>2.0<\/VersionHigh>\r\n\u00a0<VersionLow>1.0<\/VersionLow>\r\n\u00a0<AccessProcedure>1.0<\/AccessProcedure>\r\n\u00a0<AccessLocation>Andrew Wippler is awesome<\/AccessLocation>\r\n\u00a0<LocationName>MyOpenAP<\/LocationName>\r\n\u00a0<LoginURL>http:\/\/hotspot.localnet\/<\/LoginURL>\r\n\u00a0<\/Redirect>\r\n\u00a0<\/WISPAccessGatewayParam>\r\n\u00a0--><\/code><\/pre>\n

Additional Configurations<\/h3>\n

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

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

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

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

With the sudoers<\/code> file saved, you are now ready to reboot the Raspberry Pi and enjoy offering a fairly simplistic captive portal.<\/p>\n","protected":false},"excerpt":{"rendered":"

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 […]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"advanced_seo_description":"","jetpack_seo_html_title":"","jetpack_seo_noindex":false,"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","enabled":false}}},"categories":[6,14,11],"tags":[42,4,43],"jetpack_publicize_connections":[],"jetpack_featured_media_url":"","jetpack-related-posts":[],"jetpack_sharing_enabled":true,"jetpack_likes_enabled":true,"_links":{"self":[{"href":"https:\/\/andrewwippler.com\/wp-json\/wp\/v2\/posts\/162"}],"collection":[{"href":"https:\/\/andrewwippler.com\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/andrewwippler.com\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/andrewwippler.com\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/andrewwippler.com\/wp-json\/wp\/v2\/comments?post=162"}],"version-history":[{"count":21,"href":"https:\/\/andrewwippler.com\/wp-json\/wp\/v2\/posts\/162\/revisions"}],"predecessor-version":[{"id":486,"href":"https:\/\/andrewwippler.com\/wp-json\/wp\/v2\/posts\/162\/revisions\/486"}],"wp:attachment":[{"href":"https:\/\/andrewwippler.com\/wp-json\/wp\/v2\/media?parent=162"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/andrewwippler.com\/wp-json\/wp\/v2\/categories?post=162"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/andrewwippler.com\/wp-json\/wp\/v2\/tags?post=162"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}