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.
@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:
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:
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]:
#!/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:
#!/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:
#!/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:
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'
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.