#!/usr/bin/env python # # Wrap me around tcpserver or inetd, example usage for tcpserver (debug): # tcpserver -HRl localhost 172.31.255.1 /root/wlportal.py # # Or put me in a CGI script in for example thttpd server: # # = Usage = # This is a wrapper script which does very basic HTML parsing and altering of # pfctl tables rules to build a basic Captive Portal, with basic sanity # checking. The ACL is IP based (this is a poor mans solution, layer2 # ACL would be much better), so don't take security very seriously. # # To get traffic by default to the portal iI requires a few special rules in # pfctl to work properly: # no rdr on { $captive_ifs } proto tcp from to !$wl_net port 80 # rdr on { $captive_ifs } proto tcp from $wl_net to !$wl_net port 80 -> 127.0.0.1 port 8082 # # Enties older than 5 minutes not being used will be removed if the (hidden) # argument action=cleanup is given as GET variable. So having this in cron (would fix it): # */5 * * * * /usr/bin/fetch -q http://172.31.255.1/wlportal?action=cleanup # # XXX: The whitelist entries first needs to contact the wlportal.py to get # added to the whitelist, this may cause issues during initial setup and hence # it might be advised to create a block of static whitelist IP addresses which # get added during boot and will never disappear. # # State : v0.6.0 # Version : $Id$ # Author : Rick van der Zwet # Licence : BSDLike http://wirelessleiden.nl/LICENSE class MultiTracebackHook: """A hook to replace sys.excepthook that shows tracebacks in syslog & HTML (using cgitb)""" def __init__(self, ident=None, enable=False): self.ident = ident if enable: self.enable() def __call__(self, etype, evalue, etb): self.handle((etype, evalue, etb)) def handle(self, info=None): import cgitb import os import sys import syslog import traceback info = info or sys.exc_info() tb = traceback.format_exception(*info) if self.ident: syslog.openlog(self.ident) prefix = '[%i]' % os.getpid() for line in tb: syslog.syslog(line) cgitb.handler(info) def enable(self): import sys sys.excepthook = self MultiTracebackHook(ident='wlportal', enable=True) import os import signal import subprocess import socket import sys import time import traceback import urlparse import yaml from jinja2 import Template # XXX: Make me dynamic for example put me in the conf file cfg = { 'autologin' : False, 'cmd_arp' : '/usr/sbin/arp', 'pfctl' : '/sbin/pfctl', 'portal_sponsor': 'Sponsor van Stichting Wireless Leiden', 'portal_url' : 'http://wirelessleiden.nl/welkom?connected_to=%s' % socket.gethostname(), 'portalroot' : '172.31.255.1', 'refresh_delay' : 3, 'tmpl_autologin': '/usr/local/etc/wlportal/autologin.tmpl', 'tmpl_login' : '/usr/local/etc/wlportal/login.tmpl', 'warnlist' : [], 'whitelist' : [], 'blacklist' : [], 'config_files' : ['/usr/local/etc/wlportal/config.yaml','/usr/local/etc/wleiden.yaml'], 'expire_time' : None, 'accessdb' : '/var/db/clients', 'net_status' : '/tmp/network.status', } # No failback if config does not exist, to really make sure the user knows if # the config file failed to parse properly or is non-existing for config_file in cfg['config_files']: if os.path.isfile(config_file): cfg.update(yaml.load(open(config_file))) internet_up = True if os.path.isfile(cfg['net_status']): internet_up = 'internet=up' in open(cfg['net_status'], 'r').read().lower() if not internet_up: cfg['warning_msg'] = "Internet Problemen: De laatste 15 minuten zijn er problemen met de (internet) verbinding geconstateerd, de gebruikers ervaring kan dus niet optimaal zijn. Onze excuses voor het eventuele ongemak. Bij aanhoudende problemen kunt u contact opnemen met gebruikers@lijst.wirelessleiden.nl" def log_registered_host(remote_mac, remote_host): """ Write statistics file, used for (nagios) monitoring purposes """ with open(cfg['accessdb'],"a") as fh: epoch = int(time.time()) fh.write("%s %s %s \n" % (epoch, remote_mac, remote_host) ) class MACnotFound(Exception): pass def get_mac(ipaddr): """ Find out the MAC for a certain IP address """ try: # ? (172.17.32.1) at 00:12:34:45:67:90 on ue0 permanent [ethernet] return subprocess.check_output(['/usr/sbin/arp', '-n' ,ipaddr], shell=False).split()[3] except subprocess.CalledProcessError: raise MACnotFound def get_active_MACs(): """ Return dictionary with active IPs as keys """ # ? (172.17.32.1) at 00:12:34:45:67:90 on ue0 permanent [ethernet] # ? (172.17.32.2) at 00:aa:bb:cc:dd:ee on ue0 expires in 964 seconds [ethernet] # ? (172.16.3.38) at (incomplete) on vr2 expired [ethernet] output = subprocess.check_output(['/usr/sbin/arp', '-n' ,'-a'], shell=False) db = {} for line in output.strip().split('\n'): i = line.split() ip = i[1][1:-1] mac = i[3] db[ip] = mac return db class PacketFilterControl(): """ Manage an Packet Filter using pfctl and table wlportal""" def add(self, ipaddr): """ Add Allow Entry in Firewall""" output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'add', ipaddr], stderr=subprocess.PIPE).communicate()[1] is_added = '1/1 addresses added.' in output return is_added def delete(self, ipaddr): """ Delete one Allow Entry to Firewall""" output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'delete', ipaddr], stderr=subprocess.PIPE).communicate()[1] def flush(self): """ Delete all Allow Entries from Firewall""" output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'flush'], stderr=subprocess.PIPE).communicate()[1] #0 addresses deleted. return int(output.strip().split('\n')[-1].split()[0]) def cleanup(self, expire_time=None): """ Delete obsolete entries and expired entries from the Firewall""" deleted_entries = 0 # Delete entries older than certain time if expire_time: output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'expire', expire_time], stdout=subprocess.PIPE).communicate()[0] # 0/0 addresses expired. deleted_entries += int(output.strip.split()[-1].split('/')[0]) # Delete entries which the MAC<>IP mapping does no longer hold. The # ``rogue'' clients, commonly seen when DHCP scope is small and IPs get # re-used frequently, are wipped and require an re-connect. stored_mac = {} if os.path.isfile(cfg['accessdb']): for line in open(cfg['accessdb'],'r'): (epoch, mac, ipaddr) = line.split() stored_mac[ipaddr] = mac # Live configuration active_mac = get_active_MACs() # Process all active ip addresses from firewall and compare changes output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'show'], stdout=subprocess.PIPE).communicate()[0] for ip in output.split(): if ip in cfg['whitelist']: # IP is whitelisted continue elif active_mac.has_key(ip) and active_mac[ip] in cfg['whitelist']: # MAC is whitelisted continue elif not active_mac.has_key(ip) and stored_mac.has_key(ip): # In-active connection - Keep entry with normal expire time, as user # might come back (temponary disconnect). continue elif active_mac.has_key(ip) and stored_mac.has_key(ip) and active_mac[ip] == stored_mac[ip]: # Active connection - previous record found - Stored v.s. Active happy continue else: self.delete(ip) deleted_entries =+ 1 return deleted_entries # Call from crontab if sys.argv[1:]: if sys.argv[1] == 'cleanup': fw = PacketFilterControl() fw.cleanup() sys.exit(0) ### BEGIN STANDALONE/CGI PARSING ### # # Query String Dictionaries qs_post = None qs = None header = [] if not os.environ.has_key('REQUEST_METHOD'): # a) We are not wrapped around in a HTTP server, so this _is_ the # HTTP server, so act like one. class TimeoutException(Exception): """ Helper for alarm signal handling""" pass def handler(signum, frame): """ Helper for alarm signal handling""" raise TimeoutException # Parse the HTTP/1.1 Content-Header (partially) signal.signal(signal.SIGALRM,handler) us = None method = None hostname = None content_length = None remote_host = None while True: try: signal.alarm(1) line = sys.stdin.readline().strip() if not line: break header.append(line) signal.alarm(0) if line.startswith('GET '): us = urlparse.urlsplit(line.split()[1]) method = 'GET' elif line.startswith('POST '): method = 'POST' us = urlparse.urlsplit(line.split()[1]) elif line.startswith('Host: '): hostname = line.split()[1] elif line.startswith('Content-Length: '): content_length = int(line.split()[1]) except TimeoutException: break # Capture Portal, make sure to redirect all to portal if hostname != cfg['portalroot']: print "HTTP/1.1 302 Moved Temponary\r\n", print "Location: http://%(portalroot)s/\r\n" % cfg, sys.exit(0) # Handle potential POST if method == 'POST' and content_length: body = sys.stdin.read(content_length) qs_post = urlparse.parse_qs(body) # Parse Query String if us and us.path == "/wlportal" and us.query: qs = urlparse.parse_qs(us.query) remote_host = os.environ['REMOTEHOST'] else: # b) CGI Script: Parse the CGI Variables if present if os.environ['REQUEST_METHOD'] == "POST": content_length = int(os.environ['CONTENT_LENGTH']) body = sys.stdin.read(content_length) qs_post = urlparse.parse_qs(body) if os.environ.has_key('QUERY_STRING'): qs = urlparse.parse_qs(os.environ['QUERY_STRING']) remote_host = os.environ['REMOTE_ADDR'] # ### END STANDALONE/CGI PARSING ### # Helpers for HTML 'templates' content = cfg.copy() content.update(extra_header='') # IP or MAC on the whitelist does not need to authenticate, used for devices # which need to connect to the internet, but has no 'buttons' to press OK. # # This assumes that devices will re-connect if they are not able to connect # to their original host, as we do not preserve the original URI. remote_mac = get_mac(remote_host) if cfg['autologin'] or remote_host in cfg['whitelist'] or remote_mac in cfg['whitelist']: qs_post = { 'action' : 'login' } if remote_mac in cfg['warnlist']: connect['status_msg'] = "U veroorzaakt overlast op het WL netwerk || You are causing WL network abuse" try: fw = PacketFilterControl() # Put authenticate use and process response if qs and qs.has_key('action'): if 'flush' in qs['action']: retval = fw.flush() content['status_msg'] += "# [INFO] Deleted %s entries\n" % retval elif 'update' in qs['action']: tech_footer = "# [INFO] Update timestamp of all entries\n" fw.update() content['status_msg'] += fw.get_log() elif 'cleanup' in qs['action']: retval = fw.cleanup(cfg['expire_time']) content['status_msg'] += "# [INFO] Deleted %s entries\n" % retval elif qs_post and qs_post.has_key('action'): if 'login' in qs_post['action']: if remote_mac in cfg['blacklist']: content['status_msg'] = "Toegang ontzegt ipv misbruik WL netwerk || Access denied due to WL network abuse" elif fw.add(remote_host): content['extra_header'] = "Refresh: %(refresh_delay)s; url=%(portal_url)s\r" % content content['status_msg'] = "Sucessfully Logged In! || " +\ """ Will redirect you in %(refresh_delay)s seconds to %(portal_url)s """ % content log_registered_host(remote_mac, remote_host) else: content['status_msg'] = "ERROR! Already Logged On" elif 'logout' in qs_post['action']: fw.delete(remote_host) content['status_msg'] = "Succesfully logged out!" except Exception, e: content['tech_footer'] += traceback.format_exc() content['status_msg'] = "
Internal error!
%s
" % traceback.format_exc() pass # Present Main Screen print """\ HTTP/1.1 200 OK\r Content-Type: text/html\r %(extra_header)s """ % content try: tmpl_file = cfg['tmpl_autologin'] if cfg['autologin'] else cfg['tmpl_login'] page = open(tmpl_file,'r').read() except IOError: page = """

%(status_msg)s

Wireless Leiden - Internet Portal

More options


Technical Details:
%(tech_footer)s
""" print Template(page).render(content)