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.

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

Install the required packages:

opkg update
opkg install opennds uhttpd uhttpd-mod-ubus coreutils-base64

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

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

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

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

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

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

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.

  • Last modified: 2026/09/27 12:58
  • by bartprokop