Setting Up openNDS with Local HTTPS FAS, RFC 8910, and RFC 8908
This guide explains how to configure openNDS to run a completely self-hosted, secure captive portal using uhttpd over HTTPS (port 8443) on OpenWrt. It implements standard RFC 8910 (DHCP Option 114) and RFC 8908 (Captive Portal API) to ensure modern operating systems (Android, iOS, Windows) detect authentication states reliably and dismiss captive sheets cleanly without background polling overhead.
Architecture Overview
- openNDS v10.3.1+: Handles firewall filtering, client tracking, and authorization state via `ndsctl`.
- uhttpd-ssl: Serves the portal web UI and custom CGI endpoints on port `8443`.
- RFC 8910 (DHCP Option 114): Advertises the API endpoint URL directly to connecting clients.
- RFC 8908 (`/cgi-bin/captive-api.sh`): Returns standard `application/captive+json` indicating whether the device is captive or authorized.
- Neutralized `authmon.sh`: Eliminates redundant local TLS polling loops when FAS and openNDS reside on the same machine.
@yellow: openNDS is strictly an IPv4-only firewall manager. Ensure IPv6 Router Advertisements and DHCPv6 are disabled on the managed guest interface to prevent unauthenticated traffic bypass.
1. Prerequisites
Install the required packages:
opkg update opkg install opennds uhttpd uhttpd-mod-ubus coreutils-base64
2. Configuring uHTTPd for HTTPS on Port 8443
To serve the portal and CGI scripts over HTTPS on an alternate port without conflicting with the LuCI web interface (which typically occupies ports `80` and `443`), create a dedicated `uhttpd` server instance.
Creare necessary folders:
mkdir -p /www/fas/cgi-bin
Edit `/etc/config/uhttpd` and append a dedicated configuration block for the captive portal:
- /etc/config/uhttpd
config uhttpd 'captive_portal' # Bind HTTPS to port 8443 across all IPv4 interfaces list listen_https '0.0.0.0:8443' # Document root hosting index.html and /cgi-bin/ option home '/www/fas' # Enable CGI script execution in /cgi-bin option cgi_prefix '/cgi-bin' # Paths to your TLS certificate and private key # (e.g. Let's Encrypt / ACME certs or uhttpd self-signed cert) option cert '/etc/uhttpd.crt' option key '/etc/uhttpd.key' # General connection settings option max_requests '30' option max_connections '100' option network_timeout '30' option script_timeout '60'
<note tip> If you obtain a valid public certificate via the `acme` package (recommended so mobile devices do not present untrusted certificate warnings), point `option cert` and `option key` to the issued fullchain and private key paths under `/etc/acme/`. </note>
Restart the web server to apply the changes:
/etc/init.d/uhttpd restart
Verify that `uhttpd` is actively listening on port 8443:
netstat -tulpn | grep 8443
3. Configuring DHCP Option 114 (RFC 8910)
In `/etc/config/dhcp`, configure your guest interface to advertise the API endpoint URL using DHCP Option 114:
- /etc/config/dhcp
config dhcp 'guest' option interface 'guest' option start '10' option limit '150' option leasetime '1h' # Disable IPv6 on captive interface option ra '0' option dhcpv6 '0' # RFC 8910 Captive Portal API URI list dhcp_option '114,https://portal.example.com:8443/cgi-bin/captive-api.sh'
Restart `dnsmasq`:
/etc/init.d/dnsmasq restart
4. RFC 8908 API Endpoint (`/www/cgi-bin/captive-api.sh`)
Create the executable CGI script `/www/cgi-bin/captive-api.sh`[cite: 2]. This script reads the client's `$REMOTE_ADDR` and queries `ndsctl` directly for authentication status[cite: 2]:
- /www/cgi-bin/captive-api.sh
#!/bin/sh # RFC 8908 requires application/captive+json and no-cache headers[cite: 2] printf "Content-Type: application/captive+json\r\n"[cite: 2] printf "Cache-Control: private, no-cache, no-store, must-revalidate\r\n\r\n"[cite: 2] CLIENT_IP="$REMOTE_ADDR"[cite: 2] PORTAL_URL="https://portal.example.com:8443/cgi-bin/entry.sh" VENUE_URL="https://example.com"[cite: 2] IS_CAPTIVE="true"[cite: 2] SECONDS_REMAINING=0[cite: 2] if [ -n "$CLIENT_IP" ]; then[cite: 2] CLIENT_DATA=$(ndsctl json "$CLIENT_IP" 2>/dev/null)[cite: 2] # openNDS outputs capitalized "state":"Authenticated" if echo "$CLIENT_DATA" | grep -qi '"state":"authenticated"'; then IS_CAPTIVE="false"[cite: 2] SESSION_END=$(echo "$CLIENT_DATA" | grep -i '"session_end"' | tr -dc '0-9') NOW=$(date +%s)[cite: 2] if [ -n "$SESSION_END" ] && [ "$SESSION_END" -gt "$NOW" ]; then[cite: 2] SECONDS_REMAINING=$((SESSION_END - NOW))[cite: 2] else SECONDS_REMAINING=86400[cite: 2] fi fi fi[cite: 2] cat <<EOF { "captive": ${IS_CAPTIVE}, "user-portal-url": "${PORTAL_URL}", "venue-info-url": "${VENUE_URL}", "seconds-remaining": ${SECONDS_REMAINING}, "can-extend-session": false } EOF
Make it executable:
chmod +x /www/cgi-bin/captive-api.sh
5. Gateway Normalizer (`/www/cgi-bin/entry.sh`)
Because clients connecting via RFC 8910 hit the portal directly (bypassing openNDS HTTP interception), they do not carry the `?fas=` Base64 query parameter.
The entry wrapper resolves the client MAC address from the kernel ARP table and synthesizes the required Base64 parameter expected by your frontend:
- /www/cgi-bin/entry.sh
#!/bin/sh CLIENT_IP="$REMOTE_ADDR" # Pass-through if openNDS already provided a fas parameter if [ -n "$QUERY_STRING" ] && echo "$QUERY_STRING" | grep -q "fas="; then printf "Status: 302 Found\r\n" printf "Location: /index.html?%s\r\n\r\n" "$QUERY_STRING" exit 0 fi CLIENT_MAC="" if [ -n "$CLIENT_IP" ]; then CLIENT_MAC=$(awk -v ip="$CLIENT_IP" '$1 == ip {print $4}' /proc/net/arp 2>/dev/null) if [ -z "$CLIENT_MAC" ] \vert{}\vert{} [ "$CLIENT_MAC" = "00:00:00:00:00:00" ]; then CLIENT_MAC=$(ip neigh show "$CLIENT_IP" 2>/dev/null | awk '{print $5}' | head -n 1) fi fi PAYLOAD="clientip=${CLIENT_IP}, clientmac=${CLIENT_MAC}, gatewayname=Guest%20WiFi" B64_FAS=$(printf '\%s' "$PAYLOAD" | base64 | tr -d '\r\n') printf "Status: 302 Found\r\n" printf "Location: /index.html?fas=%s\r\n\r\n" "$B64_FAS" exit 0
Make it executable:
chmod +x /www/cgi-bin/entry.sh
6. Login Handler & OS Modal Dismissal (`/www/cgi-bin/login.sh`)
When the client submits credentials/terms acceptance, `/www/cgi-bin/login.sh` runs `ndsctl auth` locally and outputs signals to trigger sheet closure on Android and iOS:
- /www/cgi-bin/login.sh
#!/bin/sh read -r POST_DATA # Parse parameters (e.g. clientmac, email) CLIENT_MAC=$(echo "$POST_DATA" | tr '&' '\n' | grep '^clientmac=' | cut -d= -f2 | sed 's/%3A/:/g; s/%3a/:/g') # Authorize client for 24 hours (1440 minutes) via local IPC if [ -n "$CLIENT_MAC" ]; then ndsctl auth "$CLIENT_MAC" 1440 >/dev/null 2>&1 fi printf "Content-Type: text/html; charset=utf-8\r\n\r\n" cat <<EOF <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Connected</title> </head> <body> <h2>Connected!</h2> <p>You are now connected to the network. You may close this window or tap <strong>Done</strong>.</p> <script> // Trigger Android connectivity check var g = new Image(); g.src = "http://connectivitycheck.gstatic.com/generate_204?" + Date.now(); // Trigger Apple CNA detection var a = new Image(); a.src = "http://captive.apple.com/hotspot-detect.html?" + Date.now(); // Redirect to Apple success endpoint for auto-close setTimeout(function() { window.location.href = "http://captive.apple.com/hotspot-detect.html"; }, 1500); </script> </body> </html> EOF
Make it executable:
chmod +x /www/cgi-bin/login.sh
7. openNDS Configuration & Neutralizing `authmon`
In `/etc/config/opennds`, configure FAS Level 4:
- /etc/config/opennds
config opennds option enabled '1' option faskey '1234567890abcdef' option fas_secure_enabled '4' option fasport '8443' option faspath '/cgi-bin/entry.sh' option fasremoteip '127.0.0.1' list users_to_router 'allow tcp port 8443'
Neutralizing authmon.sh
Because openNDS FAS Level 4 normally initiates a background polling loop against remote servers, running FAS locally creates a redundant loop. Neutralize `/usr/lib/opennds/authmon.sh`:
# Replace authmon.sh with an inert exit 0
cat << 'EOF' > /usr/lib/opennds/authmon.sh
#!/bin/sh
# Trap SIGTERM and SIGINT to allow procd / opennds to stop the script cleanly
trap 'exit 0' TERM INT
# Infinite sleep loop consuming zero CPU cycles
while true; do
sleep 3600 &
wait $!
done
EOF
# Set permissions and restart openNDS
chmod +x /usr/lib/opennds/authmon.sh
/etc/init.d/opennds restart
To ensure this persistence across sysupgrades, add the file to `/etc/sysupgrade.conf`:
echo "/usr/lib/opennds/authmon.sh" >> /etc/sysupgrade.conf
Verification
Test the API endpoint from an unauthenticated client:
curl -i -H "Accept: application/captive+json" https://portal.example.com:8443/cgi-bin/captive-api.sh
The response should return `“captive”: true`[cite: 2]. Upon submitting the portal form, repeat the call; the response will switch to `“captive”: false`[cite: 2], confirming that the client OS can dismiss the captive portal overlay.