diff --git a/README.md b/README.md index 191a08d..99ec031 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Either clone repository (and install dependencies either through distribution or In production put a reverse proxy in front of the local web ui (on 127.0.0.1:8000), and handle `/static` path either to `src/capport/api/static/` or your customized version of static files. +See the `contrib` directory for config of other software needed to setup a captive portal. + ## Customization Create `custom/templates` and put customized templates (from `src/capport/api/templates`) there. diff --git a/contrib/README.md b/contrib/README.md new file mode 100644 index 0000000..c3bf77d --- /dev/null +++ b/contrib/README.md @@ -0,0 +1,54 @@ +# Various other parts of a captive portal setup + +Network (HA) setup with IPv4 NAT: +- two nodes +- shared uplink L2, some transfer network (can be private or public) + * virtual addresses on active node for IPv4 and IPv6 to route traffic to +- shared downlink L2 + * virtual addresses on active node for IPv4 and IPv6 as gateway for clients + * using `fe80::1` as gateway, but also add a public IPv6 virtual address + * connected: private IPv4 prefix (e.g. CGNAT), not routed + * connected: public IPv6 prefix (routed to virtual uplink address of nodes) +- public IPv4 prefix routed virtual uplink address of nodes to use for NAT + * IPv4-traffic from clients will be (S)NATted from this prefix; size depends + on number of parallel connections you want to support. +- webserver on nodes: + * port 8080: receives transparent http redirects from the firewall; should return a temporary redirect to your portal page. + * port 80: redirect to https + * port 443: reverse-proxy to 127.0.0.1:8000 (the webui backend), but serve `/static` directly from directory (see main README) + +To access the portal page on the clients you'll need a DNS-name; it should point to the virtual addresses. In some ways downlink address is preferred, but you also might want to avoid private addresses - i.e. use the uplink IPv4 address and the downlink IPv6 address. + +Also the management traffic for the virtual address should use the uplink interface if possible (`keepalived` supports this). + +## ISC dhcpd + +See `dhcpd.conf.erb` and `dhcpd6.conf.erb`. + +Note: don't use too large IPv4 pools or dhcpd will take a long time to sync and build up the leases files. + +## Firewall / NAT + +See `nftables.conf.erb` for forwarding rules; if you want traffic shaping as well see `shape_non_whitelisted.sh`. +Local policies (ssh access and normal "host protection") are not included in the example. + +You also might want to set a high `net.netfilter.nf_conntrack_max` with sysctl (e.g. `16777216`). + +## Conntrackd + +Active/failover configuration TBD. + +I strongly recommend not to enable any tracking helpers; they often make significant holes into your stateful firewall (i.e. make clients reachable from the outside in ways they didn't actually want). + +## Keepalived (for virtual addresses) + +See `keepalived.conf.erb`. + +## Apache2 + +See `apache2.conf` (only contains "interesting" parts, probably won't start that way). +Any other webserver configured in a similar way should do just as well. + +## systemd units + +See the `systemd` directory for examples of systemd units. diff --git a/contrib/apache2.conf b/contrib/apache2.conf new file mode 100644 index 0000000..eeec041 --- /dev/null +++ b/contrib/apache2.conf @@ -0,0 +1,43 @@ +Listen 80 +Listen 443 +Listen 8080 + + + ServerName redirect + + Header always set Cache-Control "no-store" + # trailing '?' drops request query string: + RedirectMatch seeother ^.*$ https://portal.example.com? + KeepAlive off + + + + ServerName portal.example.com + ServerAlias portal-node1.example.com + + Redirect permanent / https://portal.example.com/ + + + + ServerName portal.example.com + ServerAlias portal-node1.example.com + + SSLEngine on + SSLCertificateFile "/etc/ssl/certs/portal.example.com-with-chain.crt" + SSLCertificateKeyFile "/etc/ssl/private/portal.example.com.key" + + # The static directory of your theme (or the builtin one) + Alias /static "/var/lib/python-capport/custom/static" + + Header always set X-Frame-Options DENY + Header always set Referrer-Policy same-origin + Header always set X-Content-Type-Options nosniff + Header always set Strict-Transport-Security "max-age=31556926;" + + RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME} + + ProxyRequests Off + ProxyPreserveHost On + ProxyPass /static ! + ProxyPass / http://127.0.0.1:8000/ + diff --git a/contrib/dhcpd.conf.erb b/contrib/dhcpd.conf.erb new file mode 100644 index 0000000..8e93a1a --- /dev/null +++ b/contrib/dhcpd.conf.erb @@ -0,0 +1,35 @@ +option domain-name-servers <%= ', '.join(@dns_resolvers_ipv4) %>; +option ntp-servers <%= ', '.join(@ntp_servers_ipv4) %>; + +# specify API server URL (RFC8910) +option default-url "https://<%= @service_name %>/api/captive-portal"; + +default-lease-time 600; +max-lease-time 3600; + +authoritative; + +<% if @instances.length == 2 -%> +failover peer "dhcp-peer" { + <% if @instance_index == 0 %>primary<% else %>secondary<% end %>; + address <%= @instances[@instance_index]['external_ipv4'] %>; + peer address <%= @instances[1-@instance_index]['external_ipv4'] %>; + max-response-delay 60; + max-unacked-updates 10; + load balance max seconds 3; +<% if @instance_index == 0 -%> + split 128; + mclt 180; +<%- end %> +} +<%- end %> + +subnet <%= @client_ipv4_net %> netmask <%= @client_netmask %> { + option routers <%= @client_ipv4_gateway %>; + pool { + range <%= @client_ipv4_dhcp_from %> <%= @client_ipv4_dhcp_to %>; +<% if @instances.length == 2 -%> + failover peer "dhcp-peer"; +<%- end %> + } +} diff --git a/contrib/dhcpd6.conf.erb b/contrib/dhcpd6.conf.erb new file mode 100644 index 0000000..b20d2c9 --- /dev/null +++ b/contrib/dhcpd6.conf.erb @@ -0,0 +1,13 @@ +option dhcp6.name-servers <%= ', '.join(@dns_resolvers_ipv6) %>; +option dhcp6.sntp-servers <%= ', '.join(@ntp_servers_ipv6) %>; + +# specify API server URL (RFC8910) +option dhcp6.v6-captive-portal "https://<%= @service_name %>/api/captive-portal"; + +# The delay before information-request refresh +# (minimum is 10 minutes, maximum one day, default is to not refresh) +# (set to 6 hours) +option dhcp6.info-refresh-time 3600; + +subnet6 <%= @client_ipv6 %> { +} diff --git a/contrib/keepalived.conf.erb b/contrib/keepalived.conf.erb new file mode 100644 index 0000000..0adb373 --- /dev/null +++ b/contrib/keepalived.conf.erb @@ -0,0 +1,49 @@ +global_defs { + vrrp_no_swap + checker_no_swap + script_user nobody + enable_script_security +} + +vrrp_instance capport_ipv4_default { + state BACKUP + + interface <%= @uplink_interface %> + + virtual_router_id 1 + + priority 100 + + virtual_ipaddress { + <% @uplink_virtual_ipv4 %> + <% @client_virtual_ipv4 %> dev <%= @client_interface %> + } + + promote_secondaries +} + +vrrp_instance capport_ipv6_default { + state BACKUP + + interface <%= @uplink_interface %> + + virtual_router_id 2 + + priority 100 + + virtual_ipaddress { + fe80::1:1 + <%= @uplink_virtual_ipv6 %> + fe80::1 dev <%= @client_interface %> + <%= @client_virtual_ipv6 %> dev <%= @client_interface %> + } + + promote_secondaries +} + +vrrp_sync_group capport_default { + group { + capport_ipv4_default + capport_ipv6_default + } +} diff --git a/contrib/nftables.conf.erb b/contrib/nftables.conf.erb new file mode 100644 index 0000000..b28df9a --- /dev/null +++ b/contrib/nftables.conf.erb @@ -0,0 +1,360 @@ +#!/usr/sbin/nft -f + +# Template notes: most variables should have an obvious meaning, but: +# - client_ipv4: private IPv4 network for clients (not routed outside), connected +# - client_ipv4_public: public IPv4 network for clients, must be routed to this host +# and should be blackholed here. +# - client_ipv6: public IPv6 network, must be routed to this host, connected + +# NOTE: mustn't flush full ruleset; need to keep the table `captive_mark` and its set around +# DON'T ENABLE THIS: +# flush ruleset + +# fully whitelist certain sites, e.g. VPN gateways your users are +# allowed to connect to even without accepting the terms. +define full_ipv4 = { +<%- @whitelist_full_ipv4.each do |n| -%> + <%= n %>, +<%- end -%> +} +define full_ipv6 = { +<%- @whitelist_full_ipv6.each do |n| -%> + <%= n %>, +<%- end -%> +} + +# whitelist http[s] traffic to certain sites, e.g. your +# homepage hosting the terms, OCSP responders, websites +# to setup other Wifi configurations (cat.eduroam.org) +define http_server_ipv4 = { +<%- @whitelist_http_ipv4.each do |n| -%> + <%= n %>, +<%- end -%> +} +define http_server_ipv6 = { +<%- @whitelist_http_ipv6.each do |n| -%> + <%= n %>, +<%- end -%> +} + +option ntp-servers <%= ', '.join(@ntp_servers_ipv4) %>; +option dhcp6.sntp-servers <%= ', '.join(@ntp_servers_ipv6) %>; + +# whitelist your DNS resolvers +define dns_server_ipv4 = { +<%- @dns_resolvers_ipv4.each do |n| -%> + <%= n %>, +<%- end -%> +} +define dns_server_ipv6 = { +<%- @dns_resolvers_ipv6.each do |n| -%> + <%= n %>, +<%- end -%> +} + +# whitelist your (and possible other friendly) NTP servers +define ntp_server_ipv4 = { +<%- @ntp_servers_ipv4.each do |n| -%> + <%= n %>, +<%- end -%> +} +define ntp_server_ipv6 = { +<%- @ntp_servers_ipv6.each do |n| -%> + <%= n %>, +<%- end -%> +} + +# ports to block traffic for completely +## block traffic from clients to certain server ports +define backlist_tcp = { + 25, # SMTP + 161, # SNMP + 135, # epmap (netbios/portmapping) + 137, # TCP netbios-ns + 138, # TCP netbios-dgm + 139, # netbios-ssn + 445, # microsoft-ds (cifs, samba) +} +define backlist_udp = { + 25, # SMTP + 161, # SNMP + 135, # UDP epmap (netbios/portmapping) + 137, # netbios-ns + 138, # netbios-dgm + 139, # UDP netbios-ssn + 445, # UDP microsoft-ds (cifs, samba) + 5353, # mDNS +} +## block traffic from certain client ports +define backlist_udp_source = { + 162, # SNMP trap +} + +# once a client accepted the terms: +# * define "good" (whitelisted) traffic with the following lists +# * decide in "chain forward" below whether to block other traffic completely or +# e.g. shape it to low bandwidth (also see shape_non_whitelisted.sh) +define whitelist_tcp = { + 22, # ssh + 53, # dns + 80, # http + 443, # https + 3128, # http proxy (squid) + 8080, # http alt + 110, # pop3 + 995, # pop3s + 143, # imap + 993, # imaps + 587, # submission + 465, # submissions + 1194, # openvpn default +} +# https://help.webex.com/en-us/article/WBX264/How-Do-I-Allow-Webex-Meetings-Traffic-on-My-Network +define whitelist_udp = { + 53, # dns + 123, # ntp + 443, # http/3 + 1194, # openvpn default + 51820, # wireguard default + 500, # IPsec isakmp + 4500, # IPsec ipsec-nat-t + 10000, # IPSec Cisco NAT-T + 9000, # Primary Webex Client Media + 5004, # Webex Client Media +} +# whitelist traffic to local sites +define whitelist_dest_ipv4 = { +<%- @local_site_prefixes_ipv4.each do |n| -%> + <%= n %>, +<%- end -%> +} +define whitelist_dest_ipv6 = { +<%- @local_site_prefixes_ipv6.each do |n| -%> + <%= n %>, +<%- end -%> +} + +# IPv4 HTTP redirect + SNAT +table ip nat4 {} +flush table ip nat4 +table ip nat4 { + chain prerouting { + type nat hook prerouting priority -100; + policy accept; + # needs to be marked from client interface (bit 0), captive (bit 1), and http dnat (bit 2) - otherwise accept + meta mark & 0x00000007 != 0x00000007 accept + tcp dport 80 redirect to 8080 + } + + chain postrouting { + type nat hook postrouting priority 100; + policy accept; + # needs to be marked from client interface (bit 0) - otherwise no NAT + meta mark & 0x00000001 != 0x00000001 accept + oifname <%= @uplink_interface %> snat to <%= @client_ipv4_public %> + } +} + +# IPv6 HTTP redirect +table ip6 nat6 {} +flush table ip6 nat6 +table ip6 nat6 { + chain prerouting { + type nat hook prerouting priority -100; + policy accept; + # needs to be marked from client interface (bit 0), captive (bit 1), and http dnat (bit 2) - otherwise accept + meta mark & 0x00000007 != 0x00000007 accept + tcp dport 80 redirect to 8080 + } +} + +# ipv4 + ipv6 +table inet captive_filter {} +flush table inet captive_filter +table inet captive_filter { + chain antispoof_input { + # need to accept packet for input and forward! + meta mark & 0x00000001 == 0 accept comment "accept from non-client interface" + ip saddr { 0.0.0.0, <%= @client_ipv4 %> } return + ip6 saddr { fe80::/64, <%= @client_ipv6 %> } return + drop + } + + # we need the "redirect" decision before DNAT in prerouting:-100 + chain mark_clients { + type filter hook prerouting priority -110; + policy accept; + meta mark & 0x00000001 == 0 accept comment "accept from non-client interface" + jump antispoof_input + meta mark & 0x00000002 == 0 accept comment "accept packets from non-captive clients" + # now accept all traffic to destinations allowed in captive state, and mark "redirect" packets: + jump captive_allowed + } + + chain input { + type filter hook input priority 0; + policy accept; + # TODO: limit services available to clients? iptconf might already be enough + } + + chain forward_down { + # only filter uplink -> client here: + iifname != <%= @uplink_interface %> accept + oifname != <%= @client_interface %> accept + # established connections + ct state established,related accept + # allow incoming ipv6 ping (ipv4 ping can't work due to NAT) + icmpv6 type echo-request accept + drop + } + + chain antispoof_forward { + meta mark & 0x00000001 == 0 goto forward_down comment "handle forwardings not from client interface" + ip saddr { <%= @client_ipv4 %> } return + ip6 saddr { <%= @client_ipv6 %> } return + drop + } + + chain captive_allowed_icmp { + # allow all pings to servers we allow other kind of traffic + icmp type echo-request accept + icmpv6 type echo-request accept + } + + chain captive_allowed_http { + # http + https (but not QUIC) + tcp dport { 80, 443 } accept + goto captive_allowed_icmp + } + + chain captive_allowed_dns { + # DNS, DoT, DoH + udp dport { 53, 853 } accept + tcp dport { 53, 443, 853 } accept + goto captive_allowed_icmp + } + + chain captive_allowed_ntp { + # only NTP + udp dport 123 accept + goto captive_allowed_icmp + } + + chain captive_allowed { + # all protocols for fully whitelisted + ip daddr $full_ipv4 accept + ip6 daddr $full_ipv6 accept + + ip daddr $http_server_ipv4 jump captive_allowed_http + ip6 daddr $http_server_ipv6 jump captive_allowed_http + + ip daddr $dns_server_ipv4 jump captive_allowed_dns + ip6 daddr $dns_server_ipv6 jump captive_allowed_dns + + ip daddr $ntp_server_ipv4 jump captive_allowed_ntp + ip6 daddr $ntp_server_ipv6 jump captive_allowed_ntp + + # mark (new) http clients to redirect to local http server with bit 2 + tcp dport 80 ct state new meta mark set meta mark | 0x00000004 accept comment "DNAT HTTP" + # mark packets to reject in forward with bit 3 + meta mark set meta mark | 0x00000008 comment "reject in forwarding" + } + + # for DNS+NTP + ct timeout udp-oneshot { + protocol udp; + policy = { unreplied: 10, replied: 0 } + } + + chain forward_reject { + # could reject TCP connections with tcp reset, but ICMP unreachable should be good enough + # (and it's also semantically correct): + # ip protocol tcp reject with tcp reset + # ip6 nexthdr tcp reject with tcp reset + + # but we need to close existing tcp sessions: (when client moves to captive state) + ct state != new ip protocol tcp reject with tcp reset + ct state != new ip6 nexthdr tcp reject with tcp reset + + # default icmp reject + reject with icmpx type admin-prohibited + } + + # block some ports always + chain blacklist { + tcp dport $backlist_tcp goto forward_reject + udp dport $backlist_udp goto forward_reject + udp sport $backlist_udp_source goto forward_reject + } + + # ports we assume are "proper" - still only allowed in non-captive state + chain whitelist { + ip daddr $whitelist_dest_ipv4 accept + ip6 daddr $whitelist_dest_ipv6 accept + tcp dport $whitelist_tcp accept + udp dport $whitelist_udp accept + ip protocol esp accept + ip6 nexthdr esp accept + icmp type echo-request accept + icmpv6 type echo-request accept + # icmp related to existing connections, ... + ct state established,related accept + } + + chain forward { + type filter hook forward priority 0; + policy drop; + jump antispoof_forward + # optimize conntrack timeouts for DNS and NTP + udp dport { 53, 123 } ct timeout set "udp-oneshot" + jump blacklist + # reject packets marked for rejection in mark_clients/captive_allowed (bit 3) + meta mark & 0x00000008 != 0 goto forward_reject + jump whitelist + # optional (policy): + goto forward_reject comment "drop not-whitelisted traffic completely" + # (conntrack) mark connection with bit 4 for shaping + ct mark set ct mark | 0x00000010 counter accept comment "accept shaped traffic" + } + + chain forward_con_shaped_mark { + type filter hook forward priority 10; + policy accept; + # copy conntrack mark bit 4 to meta mark bit 4 + ct mark & 0x00000010 == 0 accept comment "non shaped connection" + meta mark set meta mark | 0x00000010 counter accept comment "shaped connection" + } +} + + +# NOTE: mustn't flush this table to keep the set around +# DON'T ENABLE THIS: +# flush table inet captive_mark + +# as table wasn't flushed, at least delete the single chain we're expecting +table inet captive_mark { + chain prerouting { } +} +delete chain inet captive_mark prerouting; + +table inet captive_mark { + # set isn't recreated, i.e. keeps dynamically added members + set allowed { + type ether_addr + flags timeout + } + + chain prerouting { + type filter hook prerouting priority -150; + policy accept; + iifname != <%= @client_interface %> accept + # mark packets from client interface with bit 0 + meta mark set meta mark | 0x00000001 + ether saddr @allowed accept + # mark "captive" clients with bit 1 + meta mark set meta mark | 0x00000002 + accept + } + + # further existing elements in this table won't be cleared by loading this file! +} diff --git a/contrib/radvd.conf.erb b/contrib/radvd.conf.erb new file mode 100644 index 0000000..7ac4eb9 --- /dev/null +++ b/contrib/radvd.conf.erb @@ -0,0 +1,22 @@ +interface <%= client_interface %> +{ + AdvSendAdvert on; + AdvDefaultPreference high; + AdvSourceLLAddress off; + AdvRASrcAddress { + fe80::1; + }; + + RDNSS <%= ' '.join(@dns_resolvers_ipv6) %> { + FlushRDNSS off; + }; + + AdvOtherConfigFlag on; + + # will require radvd > 2.19 (not released yet) + # AdvCaptivePortalAPI "https://<%= @service_name %>/api/captive-portal"; + + prefix <%= client_ipv6 %> + { + }; +}; diff --git a/contrib/shape_non_whitelisted.sh b/contrib/shape_non_whitelisted.sh new file mode 100644 index 0000000..f8adda7 --- /dev/null +++ b/contrib/shape_non_whitelisted.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +limit_iface() { + local dev=$1 + local shape_rate=$2 # "guaranteed" + local shape_ceil=$3 # "upper limit" + + tc qdisc delete dev "${dev}" root 2>/dev/null + tc qdisc add dev "${dev}" root handle 1: htb default 0x11 + # basically no limit for default traffic + tc class add dev "${dev}" parent 1: classid 1:11 htb rate 10Gbit ceil 100Gbit quantum 100000 + # limit "bad" (not whitelisted) traffic + tc class add dev "${dev}" parent 1: classid 1:12 htb prio 1 rate "${shape_rate}" ceil "${shape_ceil}" + # use "codel" qdisc for both classes, but with larger queue for default traffic + tc qdisc add dev "${dev}" parent 1:11 handle 11: codel limit 20000 + tc qdisc add dev "${dev}" parent 1:12 handle 12: codel + + # sort into bad class based on netfilter mark (if bit 0x10 is set) + tc filter add dev "${dev}" parent 1: prio 1 basic match 'meta(nf_mark mask 0x10 eq 0x10)' classid 1:12 +} + +uplink=$1 +downlink=$2 + +if [ -z "${uplink}" -o -z "${downlink}" ]; then + echo >&2 "Missing uplink and downlink interface names" + exit 1 +fi + +limit_iface "${uplink}" "1Mbit" "1Mbit" +limit_iface "${downlink}" "1Mbit" "1Mbit" diff --git a/contrib/systemd/capport-enforcement.service b/contrib/systemd/capport-enforcement.service new file mode 100644 index 0000000..847a5b4 --- /dev/null +++ b/contrib/systemd/capport-enforcement.service @@ -0,0 +1,18 @@ +[Unit] +Description=Captive Portal enforcement service +Wants=basic.target +After=basic.target network.target +ConditionFileIsExecutable=/var/lib/python-capport/start-control.sh +ConditionPathIsDirectory=/var/lib/python-capport/venv + +# TODO: start as unprivileged user but with CAP_NET_ADMIN ? +[Service] +Type=notify +WatchdogSec=10 +ExecStart=/var/lib/python-capport/start-control.sh +Restart=always +ProtectSystem=full +ProtectHome=true + +[Install] +WantedBy=multi-user.target diff --git a/contrib/systemd/capport-nftables.service b/contrib/systemd/capport-nftables.service new file mode 100644 index 0000000..b3634ac --- /dev/null +++ b/contrib/systemd/capport-nftables.service @@ -0,0 +1,18 @@ +[Unit] +Description=NFT Firewall Shim for Captive Portal +Wants=network-pre.target +Before=network-pre.target shutdown.target +Conflicts=shutdown.target +DefaultDependencies=no + +[Service] +Type=oneshot +RemainAfterExit=yes +StandardInput=null +ProtectSystem=full +ProtectHome=true +ExecStart=/usr/sbin/nft -f /etc/nftables.conf +ExecReload=/usr/sbin/nft -f /etc/nftables.conf + +[Install] +WantedBy=sysinit.target diff --git a/contrib/systemd/capport-shaping.service.erb b/contrib/systemd/capport-shaping.service.erb new file mode 100644 index 0000000..b0bbb6d --- /dev/null +++ b/contrib/systemd/capport-shaping.service.erb @@ -0,0 +1,15 @@ +[Unit] +Description=Captive Portal traffic shaping +Wants=basic.target +After=basic.target network.target + +[Service] +Type=oneshot +RemainAfterExit=yes +StandardInput=null +ProtectSystem=full +ProtectHome=true +ExecStart=/etc/capport-tc.sh <%= @uplink_interface %> <%= @client_interface %> + +[Install] +WantedBy=multi-user.target diff --git a/contrib/systemd/capport-webui.service b/contrib/systemd/capport-webui.service new file mode 100644 index 0000000..dc8aa04 --- /dev/null +++ b/contrib/systemd/capport-webui.service @@ -0,0 +1,18 @@ +[Unit] +Description=Captive Portal web ui service +Wants=basic.target +After=basic.target network.target +ConditionFileIsExecutable=/var/lib/python-capport/start-control.sh +ConditionPathIsDirectory=/var/lib/python-capport/venv + +[Service] +User=capport +Type=notify +WatchdogSec=10 +ExecStart=/var/lib/python-capport/start-api.sh +Restart=always +ProtectSystem=full +ProtectHome=true + +[Install] +WantedBy=multi-user.target