Improvements, suggestions & fixes are welcome!
Captive portals can be a pain. Here's an opinionated and no-doubt entirely imperfect guide to setting one up for a WiFi access point on Ubuntu (tested on 20+), utilising Network Manager, DNSMasq, HA Proxy and (optionally) Let's Encrypt for a secure, locally hosted landing page.
Note: This setup was originally designed for an offline WLAN, providing access to a small number of locally hosted domains ... think the WiFi media portal on a flight or boat. If you are looking to provide internet access behind a captive portal then this guide won't get you all the way there. That said, many routers have this capability built in, as do any number of open source router firmware solutions. So you probably don't need to roll your own. If you'd like to try anyway, Ha Proxy Stick Tables would probably come in handy. Very happy to update the guide with any provided working examples.
- It's probably best to start with a clean Ubuntu install. This setup was originally implemented on
20.04
. I'll leave that bit up to you. - Security hardening is not covered, nor webservers (beyond a proof of concept), or any other basic system setup items like local networking or remote access. You should look into these.
- The domain in use across the guide is
https://captivity.bytes
... make sure to replace this with your own. - This guide uses a https endpoint for the landing page. For this to work you will need a registered domain (hint: sub domain) where you control the DNS (it leverages DNS auth for certificate installation). If you'd like to stick with the easier non-https option, just skip the final section "Securing our Portal with Let's Encrypt".
- Wireless Broadband Alliance: Captive Network Portal Behavior
- Apple: How to modernize your captive network
- First, let's ensure Network Manager is installed (e.g
$ sudo apt install network-manager
) - Modern Ubuntu uses
netplan
for network configurations .. we'll use it to set up a WiFi access point:
# /etc/netplan/wifi.yaml
network:
version: 2
renderer: NetworkManager
wifis:
wlp4s0:
addresses:
- 192.168.10.1/24
access-points:
"Captivity_Bytes":
password: "freedom"
mode: ap
- Next is DNS, we'll tell Network Manager to use DNSMasq by adding
dns=dnsmasq
under[main]
in the config e.g:
# /etc/NetworkManager/NetworkManager.conf
[main]
dns=dnsmasq
- We want DNSMasq to forward all traffic via our local IP (caveats? tell me more). We also advertise our "modern" captive portal here:
# /etc/NetworkManager/dnsmasq-shared.d/00-redirects.conf
# some global options
domain-needed
bogus-priv
expand-hosts
# advertise modern captive portal (update the hostname, leaving the "/captive-api" endpoint)
dhcp-option-force=114,https://captivity.bytes/captive-api
# Wildcard redirect
address=/#/192.168.10.1
- Finally we'll apply our netplan
$ netplan --debug apply
and, all ok, restart ubuntu - WiFi access point is a go-go! Jump on your slick apple or android device (others?), open WiFi and see if you can find your no-doubt radly-named SSID. Not there? Explode it all.
HAProxy is great - simple and flexible. We'll be using it for all the heavy lifting in our captive portal, to "reverse proxy" / carefully map out each weird and wonderful captive scenario to the appropriate portal-enabling response.
Note that this section utilises a bunch of files attached to the gist, which you're expected to copy into appropriate locations at appropriate times. You might also notice mentions of HTTPS in haproxy.cfg
.. you can safely ignore these until you choose to follow the "Securing our Portal with Let's Encrypt" section (further down the guide).
- Let's install HAProxy:
$ sudo apt install haproxy
- To be overly cautious we'll make a copy of the default config before we meddle:
$ sudo cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.old
- Insurance policy in-hand we can now update the original config with our own carefully curated
haproxy.cfg
(attached below). Have a close look and note how the OS-specific 'captive portal endpoint matching logic' works. So simple. So much fun. Does linux/canonical/samsung have their own endpoints? Why not. - Next we need to create a directory for our captive portal voodoo magic (
$ sudo mkdir /etc/haproxy/captive/
) and copy each of the attachedcaptive-*
files into it. That'scaptive-modern.http
,captive-android.http
,captive-android.lst
,captive-apple.http
andcaptive-apple.lst
. The*.lst
matches endpoints called by the OS,*.http
is the required response. You'll see that all these files are referenced in the config. - Finally we can test our config
$ /usr/sbin/haproxy -f /etc/haproxy/haproxy.cfg -c
and then reload$ sudo service haproxy reload
- Config passed the test? HAProxy running? Boom! No? Bin fire.
- Ok so we'll keep this real simple. Let's make a directory (
$ mkdir ~/www
) and an (admittedly invalid) html file:$ echo "captivity bytes" >> ~/www/index.html
- Next let's serve it with pre-installed Python (on port 8001, per the
portal
backend on line 78 of our HAProxy config)$ python3 -m http.server 8001 -d ~/www
- You can check the server is working locally in another terminal session:
$ curl 127.0.0.1:8001
- Even better, use HAProxy's stats console. Browse to {NETWORK_IP}:8888 from another device on the same network (username and pass are on the last line in
haproxy.cfg
). Theportal
backend should be up in lights.
- Ok, now for the good stuff. Grab your slick device and hit connect on your new WiFi creation". All going to plan you should get a notification almost immediately stating that you "need to sign in" / "no internet" / somesuch. No notification? Try opening a browser. Still nothing? Try visiting a website. Still no banana? Throw your device on the floor, stamp on it with your heel, and see if that fixes things.
>> "captivity bytes"
We have a working captive portal! To secure it we'll use Let's Encrypt / Certbot to obtain and automatically renew a certificate, authenticating via DNS, for a custom domain that we own. If you don't own a custom domain (or don't want a secure portal) you can skip this step like you might do breakfast, keeping in mind that breakfast is (arguably) the most important meal of the day.
Apart from security, HTTPS connections are great because they unlock a number of useful browser-level features for our portal (such as storage and service workers) and generally just calm suspicious minds.
We're using DNS authentication so we can prove ownership without a publicly accessible web server. This also gives us the option of creating multiple offline deployments with certificates issued for the same domain (or sub domain). Note that ongoing intermittent internet access (< 3 monthly) is necessary to ensure our certificates don't expire.
The following steps are a combination / mutation of more detailed guides available in any number of places on the web. For example Certbot's own guide (select the "wildcard" tab), Digital Ocean's: How To Acquire a Let's Encrypt Certificate Using DNS Validation with acme-dns-certbot on Ubuntu 18.04 and Skarlso's: How to HTTPS with Hugo LetsEncrypt and HAProxy. If you want more detail please check these out.
- We're using snap (because it's recommended by certbot) so we'll make sure it's current:
$ sudo snap install core; sudo snap refresh core
- ... next we'll install certbot with full system access (classic):
$ sudo snap install --classic certbot
- ... ensure it can be executed:
$ sudo ln -s /snap/bin/certbot /usr/bin/certbot
- ... and ensure our dns plugin (see next step) also has the full system access it needs:
$ sudo snap set certbot trust-plugin-with-root=ok
Certbot has a bunch of DNS plugins for popular providers, find yours in the list then follow the provided steps to install and retrieve the appropriate credentials. The steps for Cloudflare are outlined below.
- First we'll install our provider-specific dns plugin (full list here):
$ sudo snap install certbot-dns-cloudflare
- Next, we'll need to retrieve credentials from our DNS provider (Cloudflare in this case) and secure them somewhere:
$ sudo chmod 600 ~/.secrets/certbot/cloudflare.ini
- Before we can use a certificate in HAProxy, we need to combine two of the Certbot-issued certificate files into one, stored in a HAProxy-friendly location (
$ sudo mkdir /etc/haproxy/certs/
). We'll do this when the certificate is created and then each time it renews. Let's setup a special post-hook file to do just that (referenced in our Certbot command at step 2, below):
#!/usr/bin/env bash
# /etc/letsencrypt/renewal-hooks/post/haproxy.sh
# concatenate cert files
DOMAIN='captivity.bytes' sudo -E bash -c 'cat /etc/letsencrypt/live/$DOMAIN/fullchain.pem /etc/letsencrypt/live/$DOMAIN/privkey.pem > /etc/haproxy/certs/$DOMAIN.pem'
# reload HAProxy
service haproxy reload
- Almost there. Once we've made our post-hook executable (
$ sudo chmod +x /etc/letsencrypt/renewal-hooks/post/haproxy.sh
) it's time to generate our certificate (ensure you update the domain after-d
below):
$ sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials ~/.secrets/certbot/cloudflare.ini \
--post-hook /etc/letsencrypt/renewal-hooks/post/haproxy.sh \
-d captivity.bytes
- Bingo! Everything going to plan Certbot should have downloaded your new certificates into
/etc/letsencrypt/live/
and run your post-hook to create a new, combined cert for HAProxy. Check it:sudo ls -l /etc/haproxy/certs/
- Because Certbot is awesome our certificates will be set to automatically renew within 90 days (you can check this:
$ systemctl list-timers '*certbot*'
and$ sudo certbot renew --dry-run
), at which point our post-hook will run again and update the combined cert. Winning.
You might have noticed a number of commented lines in the HAProxy config that were specific to https. These basically instruct you to comment/uncomment specific lines in the context to enable a secure connection. Now all the ingredients are in place, this is all that's left to do.
- Follow the instructions to edit our HAProxy configuration on lines 39, 55, 62 & 81 of
/etc/haproxy/haproxy.cfg
. - Edit
/etc/haproxy/captive/captive-modern.http
, switching the protocols underuser-portal-url
andvenue-info-url
from http => https (this may not be entirely necessary due to redirects in place ... but why not) - Reload HAProxy to use the updated config and resource:
$ sudo service haproxy reload
- Grab your device and re-connect to WiFi. All going to plan you should be captive and swimming in green 😎
This guide has been hastily put together from the scraps of a larger project. Please let me know of any challenges / issues / oversights / criticisms in the comments. Improvements happily accepted. Thanks for reading.
Hey @lukas-lang thanks for trialing! Just to confirm, you're simply switching out the captive_android 503 to:
Yes that should work to get HAProxy running, however you're right - older android devices expect a valid 204 response. You could try switching out the response in
captive-android.http
for google's default atcurl --head http://clients3.google.com/generate_204
:That said, modern devices (from Android 11 at least) will actually use the modern captive portal protocol and so will behave correctly via the
captive_modern
backend.Let me know if you have any luck. I'll have a play with the latest version of HAProxy as soon as I get a chance and let you know what I find.