Initial commit
This commit is contained in:
commit
11c3655907
6 changed files with 200 additions and 0 deletions
87
files/captive-portal.py
Normal file
87
files/captive-portal.py
Normal file
|
|
@ -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<days>[0-9]+)d)?((?P<hours>[0-9]+)h)?((?P<minutes>[0-9]+)m)?((?P<seconds>[0-9]+)s)?((?P<milliseconds>[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<val>[^ ]+) expires (?P<expires>[^ ]+) \}')
|
||||
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'])
|
||||
|
||||
15
files/captive-portal.service
Normal file
15
files/captive-portal.service
Normal file
|
|
@ -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
|
||||
12
handlers/main.yml
Normal file
12
handlers/main.yml
Normal file
|
|
@ -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
|
||||
57
tasks/main.yml
Normal file
57
tasks/main.yml
Normal file
|
|
@ -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'
|
||||
25
templates/captive-portal-rules.nft.j2
Normal file
25
templates/captive-portal-rules.nft.j2
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
4
templates/captive-portal.conf.j2
Normal file
4
templates/captive-portal.conf.j2
Normal file
|
|
@ -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 }}
|
||||
Loading…
Add table
Add a link
Reference in a new issue