simple-captive-portal: add new package

This package intercepts/blocks traffic from 'interface' and
redirects http requests to a splash page that you can personalize,
stored in '/etc/simple-captive-portal/'.
After clicking on 'connect' the MAC of the client is allowed,
for 'timeout' seconds (24h), allowing both IPv4 and IPv6.

If your guest interface defaults to input drop or reject (recommended),
make sure to allow tcp 8888-8889 on input (and also dns and dhcp).

Signed-off-by: Etienne Champetier <champetier.etienne@gmail.com>
This commit is contained in:
Etienne Champetier
2025-06-22 08:06:36 -04:00
parent 6f9b867541
commit e0d761e79b
9 changed files with 260 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=simple-captive-portal
PKG_VERSION:=2025.06.22
PKG_RELEASE:=1
PKG_MAINTAINER:=Etienne CHAMPETIER <champetier.etienne@gmail.com>
PKG_LICENSE:=GPL-2.0-or-later
include $(INCLUDE_DIR)/package.mk
Build/Compile=
define Package/simple-captive-portal
SUBMENU:=Captive Portals
SECTION:=net
CATEGORY:=Network
TITLE:=Simple captive portal
PKGARCH:=all
DEPENDS:=+uhttpd +uhttpd-mod-lua +luci-lib-ip
endef
define Package/simple-captive-portal/install
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/etc/config/simple-captive-portal $(1)/etc/config/simple-captive-portal
$(INSTALL_DIR) $(1)/etc/hotplug.d/net/
$(INSTALL_DATA) ./files/etc/hotplug.d/net/00-simple-captive-portal $(1)/etc/hotplug.d/net/00-simple-captive-portal
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/etc/init.d/simple-captive-portal $(1)/etc/init.d/simple-captive-portal
$(INSTALL_DIR) $(1)/etc/simple-captive-portal/
$(INSTALL_DATA) ./files/etc/simple-captive-portal/index.html $(1)/etc/simple-captive-portal/index.html
$(INSTALL_DIR) $(1)/usr/share/simple-captive-portal/
$(INSTALL_DATA) ./files/usr/share/simple-captive-portal/capabilities.json $(1)/usr/share/simple-captive-portal/capabilities.json
$(INSTALL_DATA) ./files/usr/share/simple-captive-portal/portal.lua $(1)/usr/share/simple-captive-portal/portal.lua
$(INSTALL_DATA) ./files/usr/share/simple-captive-portal/redirect.lua $(1)/usr/share/simple-captive-portal/redirect.lua
endef
define Package/simple-captive-portal/conffiles
/etc/simple-captive-portal/
/etc/config/simple-captive-portal
endef
define Package/simple-captive-portal/description
Provides a simple captive portal splash page.
endef
$(eval $(call BuildPackage,simple-captive-portal))

View File

@@ -0,0 +1,52 @@
# Simple captive portal
This package intercepts/blocks traffic from 'interface' and
redirects http requests to a splash page that you can personalize,
stored in '/etc/simple-captive-portal/'.
After clicking on 'connect' the MAC of the client is allowed,
for 'timeout' seconds (24h), allowing both IPv4 and IPv6.
If your guest interface defaults to input drop or reject (recommended),
make sure to allow tcp 8888-8889 on input (and also dns and dhcp).
Here an example (ipv4) firewall configuration.
```
config zone
option name 'guest'
option forward 'REJECT'
option output 'ACCEPT'
option input 'REJECT'
option network 'guest'
config forwarding
option dest 'wans'
option src 'guest'
config rule
option name 'guest-dhcp'
option src 'guest'
option family 'ipv4'
option proto 'udp'
option dest_port '67'
option target 'ACCEPT'
config rule
option name 'guest-dns'
option src 'guest'
option family 'ipv4'
list proto 'tcp'
list proto 'udp'
option dest_port '53'
option target 'ACCEPT'
config rule
option name 'guest-portal'
option src 'guest'
option family 'ipv4'
list proto 'tcp'
option dest_port '8888-8889'
option target 'ACCEPT'
```
To disable simple-captive-portal, just unset/comment 'interface' in the uci config.

View File

@@ -0,0 +1,5 @@
config simple-captive-portal main
#option interface guest
#option port_redirect 8888
#option port_portal 8889
#option timeout 86400

View File

@@ -0,0 +1,5 @@
INTF=$(uci -q get simple-captive-portal.main.interface)
if [ "$ACTION" = add -a "$DEVICENAME" == "$INTF" ]; then
/etc/init.d/simple-captive-portal firewall
fi

View File

@@ -0,0 +1,81 @@
#!/bin/sh /etc/rc.common
START=10
USE_PROCD=1
EXTRA_COMMANDS='firewall'
firewall() {
local INTF PORT_REDIRECT TIMEOUT
config_load "simple-captive-portal"
config_get INTF main interface
[ -z "${INTF}" ] && exit 0
config_get PORT_REDIRECT main port_redirect 8888
config_get TIMEOUT main timeout 86400
/usr/sbin/nft -f- <<EOF
table inet simple-captive-portal
flush table inet simple-captive-portal
table inet simple-captive-portal {
set guest_macs {
type ether_addr
timeout ${TIMEOUT}s
}
chain prerouting {
type nat hook prerouting priority mangle; policy drop;
iif != ${INTF} accept
ether saddr @guest_macs accept
tcp dport 80 redirect to ${PORT_REDIRECT}
fib daddr . iif type { local, broadcast, multicast } accept
reject
}
}
EOF
}
boot() {
BOOT=1
start "$@"
}
start_service() {
# firewall() called by hotplug on boot
[ -z "${BOOT}" ] && firewall
local INTF PORT_REDIRECT PORT_PORTAL
config_load "simple-captive-portal"
config_get INTF main interface
[ -z "${INTF}" ] && exit 0
config_get PORT_REDIRECT main port_redirect 8888
config_get PORT_PORTAL main port_portal 8889
procd_open_instance redirect
procd_set_param command /usr/sbin/uhttpd -f -c /dev/null -k0 -h /etc/simple-captive-portal/ -l / -L /usr/share/simple-captive-portal/redirect.lua -p "${PORT_REDIRECT}"
procd_set_param env PORT_PORTAL=${PORT_PORTAL}
procd_add_jail simple-captive-portal-redirect log procfs sysfs ronly
procd_add_jail_mount /etc/simple-captive-portal/
procd_add_jail_mount /usr/share/simple-captive-portal/redirect.lua
procd_add_jail_mount /usr/lib/uhttpd_lua.so
procd_set_param user nobody
procd_set_param no_new_privs
procd_set_param respawn
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
procd_open_instance portal
procd_set_param command /usr/sbin/uhttpd -f -c /dev/null -k0 -h /etc/simple-captive-portal/ -l /connect -L /usr/share/simple-captive-portal/portal.lua -p "${PORT_PORTAL}"
procd_add_jail simple-captive-portal-portal log procfs sysfs ronly
procd_add_jail_mount /etc/simple-captive-portal/
procd_add_jail_mount /usr/lib/lua/luci/ip.so
procd_add_jail_mount /usr/share/simple-captive-portal/portal.lua
procd_add_jail_mount /usr/lib/uhttpd_lua.so
procd_add_jail_mount /usr/sbin/nft
procd_add_jail_mount /bin/sh
procd_set_param capabilities /usr/share/simple-captive-portal/capabilities.json
procd_set_param user nobody
procd_set_param no_new_privs
procd_set_param respawn
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Simple captive portal</title>
</head>
<body style="text-align:center">
<a href="/connect">
<h1>Free Wifi</h1>
<p>Click here to connect</p>
</a>
</body>
</html>

View File

@@ -0,0 +1,17 @@
{
"bounding": [
"CAP_NET_ADMIN"
],
"effective": [
"CAP_NET_ADMIN"
],
"ambient": [
"CAP_NET_ADMIN"
],
"permitted": [
"CAP_NET_ADMIN"
],
"inheritable": [
"CAP_NET_ADMIN"
]
}

View File

@@ -0,0 +1,27 @@
require "luci.ip"
function handle_request(env)
local mac = nil
luci.ip.neighbors({ dest = env.REMOTE_ADDR }, function(n) mac = n.mac end)
if mac == nil then
uhttpd.send("Status: 500 Internal Server Error\r\n")
uhttpd.send("Server: simple-captive-portal\r\n")
uhttpd.send("Content-Type: text/plain\r\n\r\n")
uhttpd.send("ERROR: MAC not found for IP " .. env.REMOTE_ADDR)
return
end
ret = os.execute("nft add element inet simple-captive-portal guest_macs { " .. tostring(mac) .. " }")
if ret ~= 0 then
uhttpd.send("Status: 500 Internal Server Error\r\n")
uhttpd.send("Server: simple-captive-portal\r\n")
uhttpd.send("Content-Type: text/plain\r\n\r\n")
uhttpd.send("ERROR: failed to add mac to set\n")
return
end
uhttpd.send("Status: 200 OK\r\n")
uhttpd.send("Server: simple-captive-portal\r\n")
uhttpd.send("Content-Type: text/plain\r\n\r\n")
uhttpd.send("You now have internet access\n")
end

View File

@@ -0,0 +1,13 @@
port_portal = os.getenv("PORT_PORTAL")
function handle_request(env)
uhttpd.send("Status: 302 Found\r\n")
uhttpd.send("Server: simple-captive-portal\r\n")
if string.find(env.SERVER_ADDR, ":") == nil then
uhttpd.send("Location: http://" .. env.SERVER_ADDR .. ":" .. port_portal .. "/\r\n")
else
uhttpd.send("Location: http://[" .. env.SERVER_ADDR .. "]:" .. port_portal .. "/\r\n")
end
uhttpd.send("Cache-Control: no-cache\r\n")
uhttpd.send("Content-Length: 0\r\n\r\n")
end