#!/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 # ipfw 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 # ipfw to work properly (ajust IP details if needed): # - Rule 10010-10099 needs to be free. # - add 10100 fwd 172.20.145.1,8081 tcp from any to not 172.16.0.0/12 dst-port 80 in via wlan0 # # 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 # # The program has uses a file based persistent cache to save authenticated # ACLs, this will NOT get synced after a reboot. # # State : ALPHA # Version : $Id$ # Author : Rick van der Zwet # Licence : BSDLike http://wirelessleiden.nl/LICENSE import logging import os import pickle import re import signal import subprocess import sys import time import traceback import urlparse # XXX: Make me dynamic portalroot='172.31.255.1' portalurl='http://www.wirelessleiden.nl' fwcmd='/sbin/ipfw' arpcmd='/usr/sbin/arp' fancy_template='/etc/local/captive/include.htm' logging.basicConfig(stream=open('/var/log/wlportal.log','a'),level=logging.DEBUG) class ItemCache: """ Very basic ItemCache used for caching registered entries and other foo, no way recurrent, so use with care! """ def __init__(self, authentication_timeout=60): self.cachefile='/tmp/portal.cache' # cache[mac_address] = (ipaddr, registered_at, last_seen) self.cache = None self.arp_cache = None self.now = time.time() self.authentication_timeout = authentication_timeout def delete_all(self): self.cache = {} self.save() def delete(self,ipaddr): self.load() for mac in self.cache.keys(): if self.cache[mac][0] == ipaddr: del self.cache[mac] self.save() def load(self): """ Request cached file entries """ if self.cache == None: try: self.cache = pickle.load(open(self.cachefile,'r')) except IOError: self.cache = {} pass def load_arp_cache(self): """ Provide with listing of MAC to IP numbers """ if self.arp_cache == None: output = subprocess.Popen([arpcmd,'-na'], stdout=subprocess.PIPE).communicate()[0] self.arp_cache = {} for line in output.strip().split('\n'): # ? (172.20.145.30) at 00:21:e9:e2:7c:c6 on wlan0 expires in 605 seconds [ethernet] if not 'expires' in line: continue t = re.split('[ ()]',line) ip, mac = t[2],t[5] self.arp_cache[ip] = mac def add(self,ipaddr): """ Add entry to cache (on file) and return entry""" self.load() self.load_arp_cache() self.cache[self.arp_cache[ipaddr]] = (ipaddr, self.now, self.now) logging.debug("Adding Entry to Cache %s -> %s" % (ipaddr, self.arp_cache[ipaddr])) self.save() def save(self): """ Sync entries to disk """ # XXX: Should actually check if entry has changed at all pickle.dump(self.cache, open(self.cachefile,'w')) def update(): """ Update entries with relevant ARP cache """ self.load() self.load_arp_cache() # Update last_seen time for currently active entries for ip,mac in self.arp_cache.iteritems(): if self.cache.has_key(mac): self.cache[mac][3] = now # cleanup no longer used entries, after authentication_timeout seconds. for mac in self.cache: if self.cache[mac][3] < self.now - self.authentication_timeout: del self.cache[mac] # Sync results to disk self.save() return self.cache def get_cache(self): self.load() return self.cache def get_arp_cache(self): self.load_arp_cache() return self.arp_cache class FirewallControl: def __init__(self): self.first_rule = 10010 self.last_rule = 10099 self.available_rule = self.first_rule self.logger = '' def load(self): # Get all registered ips sp = subprocess.Popen([fwcmd,'show','%i-%i' % (self.first_rule, self.last_rule)], stdout=subprocess.PIPE, stderr=subprocess.PIPE) output = sp.communicate()[0] self.ip_in_firewall = {} if sp.returncode == 0: # 10010 32 1920 allow tcp from 172.20.145.30 to not 172.16.0.0/12,192.168.1.0/24 dst-port 80 for line in output.strip().split('\n'): t = line.split() rule, ip = t[0], t[6] self.ip_in_firewall[ip] = rule if self.available_rule == int(rule): self.available_rule += 1 else: # XXX: Some nagging about no rules beeing found perhaps? pass def cleanup(self): """ Cleanup Old Entries, mostly used for maintenance runs """ self.load() # Make sure cache matches the latest ARP version itemdb = ItemCache() cache = itemdb.get_cache() valid_ip = itemdb.get_arp_cache() # Check if all ipfw allowed entries still have the same registered MAC address # else assume different user and delete. for ip,rule in self.ip_in_firewall.iteritems(): delete_entry = False # Make sure IP is still valid if not valid_ip.has_key(ip): delete_entry = True # Also MAC needs to exists in Cache elif not cache.has_key(valid_ip[ip]): delete_entry = True # IP need to match up with registered one elif not cache[valid_ip[ip]][0] == ip: delete_entry = True # Delete entry if needed if delete_entry: output = subprocess.Popen([fwcmd,'delete',str(rule)], stdout=subprocess.PIPE).communicate()[0] self.logger += "Deleting ipfw entry %s %s\n" % (rule, ip) logging.debug('Deleting ipfw entry %s %s\n' % (rule, ip)) def add(self,ipaddr): """ Add Entry to Firewall, False if already exists """ self.load() if not self.ip_in_firewall.has_key(ipaddr): rule = "NUMBER allow tcp from IPADDR to not 172.16.0.0/12,192.168.1.0/24 dst-port 80".split() rule[0] = str(self.available_rule) rule[4] = str(ipaddr) logging.debug("Addding %s" % " ".join(rule)) output = subprocess.Popen([fwcmd,'add'] + rule, stdout=subprocess.PIPE).communicate()[0] itemdb = ItemCache() itemdb.add(ipaddr) return True else: return False def delete(self, ipaddr): itemdb = ItemCache() itemdb.delete(ipaddr) self.cleanup() def delete_all(self): itemdb = ItemCache() itemdb.delete_all() self.cleanup() def get_log(self): return self.logger # Query String Dictionaries qs_post = None qs = None header = [] # We are are HTTP server, so act like one if not os.environ.has_key('REQUEST_METHOD'): 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 != portalroot: print "HTTP/1.1 302 Moved Temponary\r\n", print "Location: http://%s/\r\n" % portalroot, 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: # 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'] # Helpers for HTML 'templates' content = { 'portalroot' : portalroot, 'portalurl' : portalurl, 'extra_header' : '', 'tech_footer' : '', 'status_msg' : '', } try: # Put authenticate use and process response if qs_post and qs_post.has_key('action'): if 'login' in qs_post['action']: fw = FirewallControl() if fw.add(remote_host): content['extra_header'] = "Refresh: 5; url=%(portalurl)s\r" % content content['status_msg'] = "Sucessfully Logged In!
" +\ """ Will redirect you in 5 seconds to %(portalurl)s """ % content else: content['status_msg'] = "ERROR! Already Logged On" elif 'logout' in qs_post['action']: fw = FirewallControl() fw.delete(remote_host) content['status_msg'] = "Succesfully logged out!" elif qs and qs.has_key('action'): if 'deleteall' in qs['action']: content['tech_footer'] += "# [INFO] Deleting all entries\n" fw = FirewallControl() fw.delete_all() content['tech_footer'] += fw.get_log() elif 'cleanup' in qs['action']: tech_footer = "# [INFO] Update timestamp of all entries\n" fw = FirewallControl() fw.update() content['tech_footer'] += fw.get_log() elif 'cleanup' in qs['action']: content['tech_footer'] += "# [INFO] Deleting all entries" fw = FirewallControl() fw.delete_all() except Exception,e: content['tech_footer'] += traceback.format_exc() content['status_msg'] = e pass # Present Main Screen print """\ HTTP/1.1 200 OK\r Content-Type: text/html\r %(extra_header)s """ % content try: page = open(fancy_template,'r').read() except IOError: page = """

%(status_msg)s

Wireless Leiden - Internet Portal

More options


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