You are not logged in.

#1 2025-06-20 20:02:45

gkamer8
Member
Registered: 2025-06-20
Posts: 2

Setting up a software access point with a WiFi captive portal

Hi-

I am at my wits' end trying to set up this WiFi captive portal, though it's been pretty fun I have to admit. I create an interface ap0 with type __ap on boot, and hostapd makes it available for new connections. I'm using dnsmasq for DHCP and DNS. I'm using my ordinary WiFi for my real internet connection, which I have to use at 2.4GHz on channel 11 because the access point only works at 2.4GHz. My device seems to support a managed connection and AP mode at the same time. NetworkManager manages my regular connection, and I have an unmanaged.conf that prevents it from hijacking ap0. I had to disable systemd-networkd at some point so it stopped interfering as well. I added the line DNSStubListener=no to /etc/systemd/resolved.conf, or else dnsmasq doesn't seem to start properly (it can't grab port 53).

I've managed to get the access point working without the captive portal so that I can connect to my WiFi network GordonGuest from my Macbook; the internet connection seems just fine. However: when I try adding the captive portal (read: modifying nftables.conf and starting a Flask server), everything goes to hell. The captive portal doesn't pop up. I can go to the sign in page manually in my browser, but it doesn't actually give me internet. Every website seems to hang (before and after hitting connect), though neverssl gives a 404 after some time for whatever reason. I noticed also that sometimes I get redirected to gstatic.com, which seems to have my login screen (have no clue what that's about). Here is the status:

I have two units in /etc/systemd/system:

[Unit]
Description=Create virtual wireless interface
Requires=sys-subsystem-net-devices-wlp3s0.device
After=network.target
After=sys-subsystem-net-devices-wlp3s0.device
[Service]
Type=oneshot
ExecStart=/usr/bin/iw dev wlp3s0 interface add ap0 type __ap addr 02:00:00:00:01:00
ExecStartPost=/usr/bin/ip addr add 192.168.12.1/24 dev ap0
ExecStartPost=/usr/bin/ip link set ap0 up
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target

and

[Unit]
Description=Create virtual wireless interface
Requires=sys-subsystem-net-devices-wlp3s0.device
After=network.target
After=sys-subsystem-net-devices-wlp3s0.device
[Service]
Type=oneshot
ExecStart=/usr/bin/iw dev wlp3s0 interface add ap0 type __ap addr 02:00:00:00:01:00
ExecStartPost=/usr/bin/ip addr add 192.168.12.1/24 dev ap0
ExecStartPost=/usr/bin/ip link set ap0 up
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
[gordon@SAGE system]$ cat captive-portal.service
[Unit]
Description=Captive portal Flask app
After=network.target nftables.service dnsmasq.service
Requires=nftables.service

[Service]
WorkingDirectory=/opt/captive-portal
ExecStart=/usr/bin/gunicorn -b 0.0.0.0:80 app:app
User=root
Restart=on-failure
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_ADMIN

[Install]
WantedBy=multi-user.target

They seem to start up just fine - and the ap0 stuff works without messing with the captive portal. One issue is that the flask app randomly fails sometimes due to "dependency" - I'll have to take care of that later. In any case, I can get it restarted and can view the flask app at port 80 in my browser. The flask app looks like:

#!/usr/bin/env python3
"""
Flask captive-portal front-end

• All HTTP “probe” URLs (Apple, Android, Windows) respond with **302 → “/”**,
  forcing the OS to launch its captive-portal helper window.
• Landing page lives at “/”.
• Clicking **Connect** (POST /login) inserts the client’s IP into the nftables
  set  inet/filter/allowed_ips, giving full Internet access.
• Works under gunicorn or `python app.py` (for quick testing).
"""

import ipaddress
import os
import subprocess
from flask import Flask, request, redirect, render_template_string, Response

app = Flask(__name__, static_url_path="", static_folder="static")

# ---------------------------------------------------------------------------
#  HTML snippets
# ---------------------------------------------------------------------------
LANDING_PAGE = """
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Welcome</title>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <style>
    body{font-family:sans-serif;text-align:center;margin-top:12vh}
    button{padding:1em 2em;font-size:1.2em}
  </style>
</head>
<body>
  <h2>Welcome to the Wi-Fi</h2>
  <p>Press “Connect” to get online.</p>
  <form method="POST" action="/login">
    <button type="submit">Connect</button>
  </form>
</body>
</html>
"""

SUCCESS_PAGE = """
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Online</title>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <style>body{font-family:sans-serif;text-align:center;margin-top:20vh}</style>
</head>
<body>
  <h3>You’re online &#10003;</h3>
</body>
</html>
"""

# ---------------------------------------------------------------------------
#  Helpers
# ---------------------------------------------------------------------------
def add_to_allowlist(ip: str) -> None:
    """Add a client IP to the nftables allow-list (idempotent)."""
    cmd = [
        "nft",
        "add",
        "element",
        "inet",
        "filter",
        "allowed_ips",
        f"{{ {ip} }}",
    ]
    # check=False → no error if the element already exists
    subprocess.run(cmd, check=False)


def client_ip() -> str:
    """Best-effort extraction of the caller’s IPv4 address."""
    return request.headers.get("X-Real-IP", request.remote_addr or "")


# ---------------------------------------------------------------------------
#  Probe endpoints — all redirect to landing page
# ---------------------------------------------------------------------------
@app.route("/generate_204")           # Android
@app.route("/hotspot-detect.html")    # Apple
@app.route("/connecttest.txt")        # Windows
@app.route("/ncsi.txt")               # Windows 10/11 extra
def probes():
    # Any 30x/200 w-noncanonical body triggers the captive-portal pop-up
    return redirect("/", code=302)


# ---------------------------------------------------------------------------
#  Landing page  (manual visit, or redirect from probes)
# ---------------------------------------------------------------------------
@app.get("/")
@app.get("/login")  # Android tries GET /login before showing the pop-up
def landing():
    return Response(LANDING_PAGE, headers={"Cache-Control": "no-store"})


# ---------------------------------------------------------------------------
#  “Connect” button handler
# ---------------------------------------------------------------------------
@app.post("/login")
def login():
    ip = client_ip()
    try:
        ipaddress.IPv4Address(ip)
    except ipaddress.AddressValueError:
        return "Invalid IP", 400

    add_to_allowlist(ip)
    return Response(SUCCESS_PAGE, headers={"Cache-Control": "no-store"})


# ---------------------------------------------------------------------------
#  Dev-time entry-point (use gunicorn in production)
# ---------------------------------------------------------------------------
if __name__ == "__main__":
    host = os.environ.get("CAPTIVE_HOST", "0.0.0.0")
    port = int(os.environ.get("CAPTIVE_PORT", "5000"))
    app.run(host=host, port=port)

My current /etc/dnsmasq.conf looks like:


interface=ap0
bind-interfaces
listen-address=192.168.12.1

# ---- Answer EVERY DNS query with 192.168.12.1 ----------------
no-resolv                         # do NOT forward upstream
address=/#/192.168.12.1           # wildcard reply (note both slashes!)

# ---- Authoritative DHCP, pool + essential options ------------
dhcp-authoritative                # override stale leases
dhcp-range=192.168.12.50,192.168.12.150,12h

#   option 3 = router (default-gateway)
#   option 6 = DNS server
dhcp-option=3,192.168.12.1
dhcp-option=6,192.168.12.1

# (Optional but handy while debugging)
log-dhcp

My current /etc/nftables.conf looks like:


#!/usr/sbin/nft -f
# Captive-portal ruleset  –  rev 3  (valid syntax)

flush ruleset

########################
#  IPv4/IPv6 filtering #
########################
table inet filter {

    set allowed_ips {          # filled by portal.py
        type ipv4_addr
        flags interval
    }

    chain input {
        type filter hook input priority 0; policy drop;

        ct state { established, related }            accept
        iifname "lo"                                 accept
        tcp  dport 22                                accept         # SSH
        iifname "ap0" udp dport 67                   accept         # DHCP
        # DNS – two rules, one per protocol
        iifname "ap0" udp dport 53                   accept
        iifname "ap0" tcp dport 53                   accept
        # Captive-portal web front-end
        iifname "ap0" tcp dport 80                   accept
    }

    chain forward {
        type filter hook forward priority 0; policy drop;

        # Clients that clicked “Connect”
        iifname "ap0" oifname "wlp3s0" ip saddr @allowed_ips  accept
        # Return traffic from the Internet
        iifname "wlp3s0" oifname "ap0" ct state { established, related } accept
    }

    chain output { type filter hook output priority 0; policy accept; }
}

################
#      NAT     #
################
table ip nat {

    chain prerouting {
        type nat hook prerouting priority dstnat; policy accept;

        # DNS hijack toward local dnsmasq
        iif "ap0" udp dport 53  redirect to 53
        iif "ap0" tcp dport 53  redirect to 53
        # (one-liner alternative, if you like the compact form:)
        # iif "ap0" meta l4proto { tcp, udp } dport 53 redirect to 53
    }

    chain postrouting {
        type nat hook postrouting priority srcnat; policy accept;
        oifname "wlp3s0" masquerade
    }
}

There's an issue with the nftables.conf where sometimes the ruleset doesn't load on boot properly it seems. For now, I've just been making sure it does get loaded at some point.


My /etc/hostapd/hostapd.conf looks like:

interface=ap0
ssid=GordonGuest
hw_mode=g
channel=11
ieee80211n=1
wmm_enabled=1

#auth_algs=1
#wpa=2
#wpa_key_mgmt=WPA-PSK
#rsn_pairwise=CCMP
#wpa_passphrase=BrotherInChrist

(you can see I disabled security when I tried making it public with the captive portal).

After choosing GordonGuest, I can see using ifconfig on my Macbook that en0 gets an address 192.168.12.116. The full output looks like:

options=...
ether 86:33...
inet6 fe80::...
inet 192.168.12.116 netmask 0xffffff00 broadcast 192.168.12.255
nd6 options=201<...
media: autoselect
status: active

The command `dig @8.8.8.8 example.com +short` gives 192.168.12.1 (as well as dig @198.168.12.1 example.com +short). That tells me that DNS is being hijacked, which I think I want (?).

So anyway, you can see that there are a lot of moving pieces here, and I'm determined to make it work. The goal of this project is to make guests to my apartment accept some embarrassing terms and conditions before they can use the WiFi (while keeping the actual, properly authenticated WiFi separate).

My suspicion is that the nftables.conf is the main issue, but I really have no ability to understand it right now (o3 wrote the current version). Thanks.

Last edited by gkamer8 (2025-06-20 20:56:16)

Offline

#2 2025-06-20 20:35:28

cryptearth
Member
Registered: 2024-02-03
Posts: 1,512

Re: Setting up a software access point with a WiFi captive portal

please use code tags to improve readability

Offline

#3 2025-06-20 20:57:17

gkamer8
Member
Registered: 2025-06-20
Posts: 2

Re: Setting up a software access point with a WiFi captive portal

cryptearth wrote:

please use code tags to improve readability

Thanks, I've done so now. This is my first post here.

Offline

Board footer

Powered by FluxBB