commit 11c36559072b22f438f65245ec32f0644e4cabde Author: Julian Rother Date: Sun Nov 9 23:19:13 2025 +0100 Initial commit diff --git a/files/captive-portal.py b/files/captive-portal.py new file mode 100644 index 0000000..536031d --- /dev/null +++ b/files/captive-portal.py @@ -0,0 +1,87 @@ +import re + +from flask import Flask, abort, request, redirect +import nftables +from pyroute2 import IPDB + +app = Flask(__name__) +app.config.from_pyfile('/etc/captive-portal.conf') + +ipdb = IPDB() +nft = nftables.Nftables() + +NFT_TIMEOUT_RE = re.compile(r'((?P[0-9]+)d)?((?P[0-9]+)h)?((?P[0-9]+)m)?((?P[0-9]+)s)?((?P[0-9]+)ms)?') +def parse_timeout(val): + m = NFT_TIMEOUT_RE.fullmatch(val) + if not m: + return 0 + groups = m.groupdict() + result = int(groups['days'] or '0') * 24*60*60*1000 + result += int(groups['hours'] or '0') * 60*60*1000 + result += int(groups['minutes'] or '0') * 60*1000 + result += int(groups['seconds'] or '0') * 1000 + result += int(groups['milliseconds'] or '0') + return result + +NFT_SET_ELEM_EXPIRES_RE = re.compile(r'elements = \{ (?P[^ ]+) expires (?P[^ ]+) \}') +def get_allowed_mac_timeout(mac_addr): + # As of libnftables 1.0.6 JSON output mode does not support get element + rc, output, error = nft.cmd(f'get element inet captive_portal allowed_macs {{ {mac_addr} }}') + if rc != 0: + return None + m = NFT_SET_ELEM_EXPIRES_RE.search(output) + if not m: + return None + _mac_addr, expires = m.groups() + if _mac_addr != mac_addr: + return None + return parse_timeout(expires) + +def add_allowed_mac(mac_addr): + # "add" does not reset the timeout if mac_addr is already in set + # "delete" fails if mac_addr is not in set + # We first make sure mac_addr is in set, then delete, then add so timeout is reset + rc, output, error = nft.cmd(f''' + add element inet captive_portal allowed_macs {{ {mac_addr} }} + delete element inet captive_portal allowed_macs {{ {mac_addr} }} + add element inet captive_portal allowed_macs {{ {mac_addr} }} + ''') + return rc == 0 + +def del_allowed_mac(mac_addr): + rc, output, error = nft.cmd(f''' + add element inet captive_portal allowed_macs {{ {mac_addr} }} + delete element inet captive_portal allowed_macs {{ {mac_addr} }} + ''') + return rc == 0 + +@app.before_request +def set_remote_mac_addr(): + inet_addr = request.environ.get('HTTP_X_FORWARDED_FOR') or request.remote_addr + request.remote_mac_addr = ipdb.interfaces[app.config['INTERFACE']].neighbours[inet_addr]['lladdr'] + +@app.route('/api/status') +def status(): + result = { + "captive": True, + "user-portal-url": app.config['USER_PORTAL_URL'], + } + mac_timeout = get_allowed_mac_timeout(request.remote_mac_addr) + if mac_timeout: + result['captive'] = False + result['venue-info-url'] = app.config['VENUE_INFO_URL'] + result['seconds-remaining'] = mac_timeout // 1000 + result['can-extend-session'] = True + return result + +@app.route('/api/login', methods=['POST']) +def login(): + if add_allowed_mac(request.remote_mac_addr): + return redirect(app.config['VENUE_INFO_URL']) + return redirect(app.config['USER_PORTAL_URL']) + +@app.route('/api/logout', methods=['POST']) +def logout(): + del_allowed_mac(request.remote_mac_addr) + return redirect(app.config['USER_PORTAL_URL']) + diff --git a/files/captive-portal.service b/files/captive-portal.service new file mode 100644 index 0000000..1bc8cc5 --- /dev/null +++ b/files/captive-portal.service @@ -0,0 +1,15 @@ +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify + +User=captive-portal +Group=captive-portal +AmbientCapabilities=CAP_NET_ADMIN + +ExecStartPre=!mkdir -p /run/captive-portal +ExecStartPre=!chown captive-portal:www-data /run/captive-portal +ExecStartPre=!chmod 0750 /run/captive-portal +WorkingDirectory=/usr/local/lib/captive-portal/ +ExecStart=/bin/gunicorn --workers 2 --bind unix:/run/captive-portal/socket captive_portal:app diff --git a/handlers/main.yml b/handlers/main.yml new file mode 100644 index 0000000..42c0a78 --- /dev/null +++ b/handlers/main.yml @@ -0,0 +1,12 @@ +- name: reload nftables + ansible.builtin.systemd_service: + name: nftables + enabled: True + state: reloaded + +- name: restart captive-portal + ansible.builtin.systemd_service: + name: captive-portal + state: restarted + enabled: true + daemon_reload: true diff --git a/tasks/main.yml b/tasks/main.yml new file mode 100644 index 0000000..267b27d --- /dev/null +++ b/tasks/main.yml @@ -0,0 +1,57 @@ +- name: install dependencies + ansible.builtin.apt: + pkg: + - python3-pyroute2 + - python3-nftables + - python3-flask + - gunicorn + +- name: copy nftables config + ansible.builtin.template: + src: captive-portal-rules.nft.j2 + dest: /etc/nftables.d/captive-portal-rules.nft + notify: reload nftables + +- name: create captive-portal group + ansible.builtin.group: + name: captive-portal + system: true + +- name: create directory /usr/local/lib/captive-portal + ansible.builtin.file: + path: /usr/local/lib/captive-portal + state: directory + +- name: create captive-portal user + ansible.builtin.user: + name: captive-portal + group: captive-portal + home: /usr/local/lib/captive-portal + create_home: false + system: true + +- name: copy captive-portal config + ansible.builtin.template: + src: captive-portal.conf.j2 + dest: /etc/captive-portal.conf + group: captive-portal + mode: '0640' + notify: restart captive-portal + +- name: copy captive-portal script + ansible.builtin.copy: + src: captive-portal.py + dest: /usr/local/lib/captive-portal/captive_portal.py + notify: restart captive-portal + +- name: copy captive-portal service + ansible.builtin.copy: + src: captive-portal.service + dest: /etc/systemd/system/captive-portal.service + notify: restart captive-portal + +- name: add cronjob to persist captive-portal allowed_macs set + ansible.builtin.cron: + name: persist captive-portal allowed_macs set + # captive-portal-sets.nft is loaded after captive-portal-rules.nft so it can overwrite the set + job: '/sbin/nft list set inet captive_portal allowed_macs > /etc/nftables.d/.captive-portal-sets.nft.tmp && mv /etc/nftables.d/.captive-portal-sets.nft.tmp /etc/nftables.d/captive-portal-sets.nft' diff --git a/templates/captive-portal-rules.nft.j2 b/templates/captive-portal-rules.nft.j2 new file mode 100644 index 0000000..06f284d --- /dev/null +++ b/templates/captive-portal-rules.nft.j2 @@ -0,0 +1,25 @@ +define captive_portal_interface = {{ captive_portal.interface }} +define captive_portal_http_redirect_port = {{ captive_portal.http_redirect_port }} + +table inet captive_portal { + set allowed_macs { + type ether_addr; + timeout {{ captive_portal.timeout }}; + } + + chain forward { + type filter hook forward priority filter; policy accept; + iifname != $captive_portal_interface return + ether saddr @allowed_macs return + + reject with icmpx type no-route + } + + chain dstnat { + type nat hook prerouting priority dstnat; policy accept; + iifname != $captive_portal_interface return + ether saddr @allowed_macs return + + tcp dport 80 redirect to :$captive_portal_http_redirect_port + } +} diff --git a/templates/captive-portal.conf.j2 b/templates/captive-portal.conf.j2 new file mode 100644 index 0000000..e059a43 --- /dev/null +++ b/templates/captive-portal.conf.j2 @@ -0,0 +1,4 @@ + +INTERFACE = {{ captive_portal.interface|tojson }} +USER_PORTAL_URL = {{ captive_portal.user_portal_url|tojson }} +VENUE_INFO_URL = {{ captive_portal.venue_info_url|tojson }}