Table of Contents

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

@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.