Changeset 10419 in hybrid


Ignore:
Timestamp:
Apr 10, 2012, 7:35:39 PM (13 years ago)
Author:
rick
Message:

Rewrote Captive Portal to use Packet Filter (pf) instead. This is much robuster and better administrable then ipfw.

Also cleaned out most of the ugly looking cache code.

Location:
branches/releng-9.0/nanobsd/files
Files:
1 added
3 edited

Legend:

Unmodified
Added
Removed
  • branches/releng-9.0/nanobsd/files/etc/crontab

    r10417 r10419  
    44#
    55SHELL=/bin/sh
    6 PATH=/etc:/bin:/sbin:/usr/bin:/usr/sbin
     6PATH=/etc:/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin
    77HOME=/var/log
    88#
     
    3333# Nagios checks
    3434*/15    *       *       *       *       root    /usr/local/sbin/check-inet-alive
     35#
     36*/5     *       *       *       *       root    /usr/local/www/wlportal/index.cgi cleanup
     37
  • branches/releng-9.0/nanobsd/files/etc/rc.conf

    r10418 r10419  
    11# No kernel dumps as we don't have a place to store them
    22dumpdev="NO"                   
    3 # We are an router/gateway (wireless to be precise)
     3
     4# We are an router/gateway (wireless to be precise) running the lvrouted
     5# routing daemon.
    46gateway_enable="YES"
     7lvrouted_enable="YES"
     8lvrouted_flags="-u -s s00p3rs3kr3t -m 28"
    59
    610# NTP server needs working config with WL network or internet on boot
     
    3034
    3135# HTTP(S) proxy server
    32 tinyproxy_enable="YES"
     36tinyproxy_enable="NO"
    3337
    3438# Make sure generated ssh keys are saved
     
    5458pf_enable="YES"
    5559pf_rules="/etc/pf.open.conf"
     60pf_flags=''
     61# Used with /etc/pf.proxy.conf
     62# pf_flags="-D ext_if=vr0 -D int_if=vr1 -D publicnat={80,443}"
     63# Used with /etc/pf.node.conf
     64# pf_flags="-D captive_portal_interfaces=wlan0,wlan1"
  • branches/releng-9.0/nanobsd/files/usr/local/www/wlportal/index.cgi

    r10201 r10419  
    88# = Usage =
    99# This is a wrapper script which does very basic HTML parsing and altering of
    10 # ipfw tables rules to build a basic Captive Portal, with basic sanity
     10# pfctl tables rules to build a basic Captive Portal, with basic sanity
    1111# checking. The ACL is IP based (this is a poor mans solution, layer2
    1212# ACL would be much better), so don't take security very seriously.
    1313#
    1414# To get traffic by default to the portal iI requires a few special rules in
    15 # ipfw to work properly (ajust IP details if needed):
    16 # - Rule 10010-10099 needs to be free.
    17 # - add 10100 fwd 172.20.145.1,8081 tcp from any to not 172.16.0.0/12 dst-port 80 in via wlan0
     15# pfctl to work properly:
     16#   no rdr on { $captive_ifs } proto tcp from <wlportal> to !$wl_net port 80
     17#   rdr on { $captive_ifs } proto tcp from $wl_net to !$wl_net port 80 -> 127.0.0.1 port 8082
    1818#
    1919# Enties older than 5 minutes not being used will be removed if the (hidden)
     
    2626# get added during boot and will never disappear.
    2727#
    28 # The program has uses a file based persistent cache to save authenticated
    29 # ACLs, this will NOT get synced after a reboot.
    30 #
    31 # State   : ALPHA
     28# State   : v0.6.0
    3229# Version : $Id$
    3330# Author  : Rick van der Zwet <info@rickvanderzwet.nl>
    3431# Licence : BSDLike http://wirelessleiden.nl/LICENSE
    3532
    36 import logging
    3733import os
    38 import pickle
    39 import re
    4034import signal
    4135import subprocess
     
    4741
    4842# XXX: Make me dynamic for example put me in the conf file
    49 conf = {
     43cfg = {
    5044  'autologin'     : False,
    5145  'cmd_arp'       : '/usr/sbin/arp',
    52   'cmd_fw'        : '/sbin/ipfw',
     46  'pfctl'         : '/sbin/pfctl',
    5347  'portal_sponsor': 'Sponsor van Stichting Wireless Leiden',
    5448  'portal_url'    : 'http://www.wirelessleiden.nl',
     
    5852  'tmpl_login'    : '/usr/local/etc/wlportal/login.tmpl',
    5953  'whitelist'     : [],
     54  'config_file'   : '/usr/local/etc/wlportal/config.yaml',
     55  'expire_time'   : None,
     56  'accessdb'      : '/var/db/clients',
    6057}
    6158
    62 
    63 logging.basicConfig(stream=open('/var/log/wlportal.log','a'),level=logging.DEBUG)
    6459
    6560# No failback if config does not exist, to really make sure the user knows if
    6661# the config file failed to parse properly or is non-existing
    67 # XXX: 5xx error code perhaps?
    68 try:
    69   conf.update(yaml.load(open('/usr/local/etc/wlportal/config.yaml')))
    70 except Exception,e:
    71   logging.error(traceback.format_exc())
    72 
    73 
    74 class ItemCache:
    75   """
    76   Very basic ItemCache used for caching registered entries and other foo, no
    77   way recurrent, so use with care!
     62if os.path.isfile(cfg['config_file']):
     63  cfg.update(yaml.load(open(cfg['config_file'])))
     64
     65def log_registered_host(remote_mac, remote_host):
    7866  """
    79 
    80   def __init__(self, authentication_timeout=60):
    81     self.cachefile='/tmp/portal.cache'
    82     # cache[mac_address] = (ipaddr, registered_at, last_seen)
    83     self.cache = None
    84     self.arp_cache = None
    85     self.now = time.time()
    86     self.authentication_timeout = authentication_timeout
    87 
    88   def delete_all(self):
    89     self.cache = {}
    90     self.save()
    91 
    92   def delete(self,ipaddr):
    93     self.load()
    94     for mac in self.cache.keys():
    95       if self.cache[mac][0] == ipaddr:
    96         del self.cache[mac]
    97     self.save()
    98 
    99 
    100   def load(self):
    101     """ Request cached file entries """
    102     if self.cache == None:
    103       try:
    104         self.cache = pickle.load(open(self.cachefile,'r'))
    105       except IOError:
    106         self.cache = {}
    107         pass
    108 
    109   def load_arp_cache(self):
    110     """ Provide with listing of MAC to IP numbers """
    111     if self.arp_cache == None:
    112        output = subprocess.Popen([conf['cmd_arp'],'-na'], stdout=subprocess.PIPE).communicate()[0]
    113        self.arp_cache = {}
    114        for line in output.strip().split('\n'):
    115          # ? (172.20.145.30) at 00:21:e9:e2:7c:c6 on wlan0 expires in 605 seconds [ethernet]
    116          if not 'expires' in line:
    117            continue
    118          t = re.split('[ ()]',line)
    119          ip, mac = t[2],t[5]
    120          self.arp_cache[ip] = mac
    121 
    122   def get_mac(self,ipaddr):
    123     self.load_arp_cache()
    124     try:
    125       return self.arp_cache[ipaddr]
    126     except KeyError:
    127       return None
    128 
    129   def add(self,ipaddr):
    130     """ Add entry to cache (on file) and return entry"""
    131     self.load()
    132     self.load_arp_cache()
    133     self.cache[self.arp_cache[ipaddr]] = (ipaddr, self.now, self.now)
    134     logging.debug("Adding Entry to Cache %s -> %s" % (ipaddr, self.arp_cache[ipaddr]))
    135     self.save()
    136 
    137   def save(self):
    138     """ Sync entries to disk """
    139     # XXX: Should actually check if entry has changed at all
    140     pickle.dump(self.cache, open(self.cachefile,'w'))
    141 
    142   def update():
    143     """ Update entries with relevant ARP cache """
    144     self.load()
    145     self.load_arp_cache()
    146     # Update last_seen time for currently active entries
    147     for ip,mac in self.arp_cache.iteritems():
    148       if self.cache.has_key(mac):
    149         self.cache[mac][3] = now
     67   Write statistics file, used for (nagios) monitoring purposes
     68  """
     69  with open(cfg['accessdb'],"a") as fh:
     70   epoch = int(time.time())
     71   fh.write("%s %s %s \n" % (epoch, remote_mac, remote_host) )
     72
     73class MACnotFound(Exception):
     74  pass
     75
     76def get_mac(ipaddr):
     77  """ Find out the MAC for a certain IP address """
     78  try:
     79    return subprocess.check_output(['/usr/sbin/arp', '-n' ,ipaddr], shell=False).split()[3][1:-1]
     80  except subprocess.CalledProcessError:
     81    raise MACnotFound
     82
     83def get_active_MACs():
     84  """ Return dictionary with active IPs as keys """
     85  output = subprocess.check_output(['/usr/sbin/arp', '-n' ,'-a'], shell=False)
     86  db = {}
     87  for line in output.strip().split('\n'):
     88    i = line.split()
     89    mac = i[3][1:-1]
     90    ip = i[5]
     91    db[ip] = mac
     92  return db
    15093   
    151     # cleanup no longer used entries, after authentication_timeout seconds.
    152     for mac in self.cache:
    153       if self.cache[mac][3] < self.now - self.authentication_timeout:
    154         del self.cache[mac]
    155 
    156     # Sync results to disk
    157     self.save()
    158     return self.cache
    159 
    160   def get_cache(self):
    161     self.load()
    162     return self.cache
    163 
    164   def get_arp_cache(self):
    165     self.load_arp_cache()
    166     return self.arp_cache
    167 
    168 
    169 class FirewallControl:
    170   def __init__(self):
    171     self.first_rule = 10010
    172     self.last_rule  = 10099
    173     self.available_rule = self.first_rule
    174     self.logger = ''
    175  
    176 
    177   def load(self):
    178     # Get all registered ips
    179     sp =  subprocess.Popen([conf['cmd_fw'],'show','%i-%i' % (self.first_rule, self.last_rule)], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    180     output = sp.communicate()[0]
    181     self.ip_in_firewall = {}
    182     if sp.returncode == 0:
    183       # 10010   32   1920 allow tcp from 172.20.145.30 to not 172.16.0.0/12 dst-port 80,443
    184       for line in output.strip().split('\n'):
    185         t = line.split()
    186         rule, ip = t[0], t[6]
    187         self.ip_in_firewall[ip] = rule
    188        
    189         if self.available_rule == int(rule):
    190           self.available_rule += 1
    191     else:
    192       # XXX: Some nagging about no rules beeing found perhaps?
    193       pass
    194 
    195   def cleanup(self):
    196     """ Cleanup Old Entries, mostly used for maintenance runs """
    197     self.load()
    198     # Make sure cache matches the latest ARP version
    199     itemdb = ItemCache()
    200     cache = itemdb.get_cache()
    201     valid_ip = itemdb.get_arp_cache()
     94
     95class PacketFilterControl():
     96  """ Manage an Packet Filter using pfctl and table wlportal"""
     97  def add(self, ipaddr):
     98    """ Add Allow Entry in Firewall"""
     99    output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'add', ipaddr], stderr=subprocess.PIPE).communicate()[1]
     100    is_added = '1/1 addresses added.' in output
     101    return is_added
     102  def delete(self, ipaddr):
     103    """ Delete one Allow Entry to Firewall"""
     104    output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'delete', ipaddr], stderr=subprocess.PIPE).communicate()[1]
     105  def flush(self):
     106    """ Delete all Allow Entries from Firewall"""
     107    output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'flush'], stderr=subprocess.PIPE).communicate()[1]
     108    #0 addresses deleted.
     109    return int(output.strip().split('\n')[-1].split()[0])
     110  def cleanup(self, expire_time=None):
     111    """ Delete obsolete entries and expired entries from the Firewall"""
     112    deleted_entries = 0
     113    # Delete entries older than certain time
     114    if expire_time:
     115      output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'expire', expire_time], stdout=subprocess.PIPE).communicate()[0]
     116      # 0/0 addresses expired.
     117      deleted_entries += int(output.strip.split()[-1].split('/')[0])
     118
     119    # Delete entries which the MAC<>IP mapping does no longer hold. The
     120    # ``rogue'' clients, commonly seen when DHCP scope is small and IPs get
     121    # re-used frequently, are wipped and require an re-connect.
     122    stored_mac = {}
     123    if os.path.isfile(cfg['accessdb']):
     124      for line in open(cfg['accessdb'],'r'):
     125        (epoch, mac, ipaddr) = line.split()
     126        stored_mac[ipaddr] = mac
     127    # Live configuration
     128    active_mac = get_active_MACs()
     129    # Process all active ip addresses from firewall and compare changes   
     130    output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'show'], stdout=subprocess.PIPE).communicate()[0]
     131    for ip in output.split():
     132      if ip in cfg['whitelist']:
     133        # IP is whitelisted
     134        continue
     135      elif active_mac.has_key(ip) and active_mac[ip] in cfg['whitelist']:
     136        # MAC is whitelisted
     137        continue
     138      elif stored_mac.has_key(ip) and active_mac[ip] == stored_mac[ip]:
     139        # previous record found
     140        # Stored v.s. Active happy
     141        continue
     142      else:
     143        self.delete(ip)
     144        deleted_entries =+ 1
     145    return deleted_entries
    202146     
    203     # Check if all ipfw allowed entries still have the same registered MAC address
    204     # else assume different user and delete.
    205     for ip,rule in self.ip_in_firewall.iteritems():
    206       delete_entry = False
    207    
    208       # Make sure IP is still valid
    209       if not valid_ip.has_key(ip):
    210         delete_entry = True
    211       # Also MAC needs to exists in Cache
    212       elif not cache.has_key(valid_ip[ip]):
    213         delete_entry = True
    214       # IP need to match up with registered one
    215       elif not cache[valid_ip[ip]][0] == ip:
    216         delete_entry = True
    217    
    218       # Delete entry if needed
    219       if delete_entry:
    220         output = subprocess.Popen([conf['cmd_fw'],'delete',str(rule)], stdout=subprocess.PIPE).communicate()[0]
    221         self.logger += "Deleting ipfw entry %s %s\n" % (rule, ip)
    222         logging.debug('Deleting ipfw entry %s %s\n' % (rule, ip))
    223 
    224 
    225   def add(self,ipaddr):
    226     """ Add Entry to Firewall, False if already exists """
    227     self.load()
    228     if not self.ip_in_firewall.has_key(ipaddr):
    229       rule = "NUMBER allow tcp from IPADDR to not 172.16.0.0/12 dst-port 80,443".split()
    230       rule[0] = str(self.available_rule)
    231       rule[4] = str(ipaddr)
    232       logging.debug("Addding %s" % " ".join(rule))
    233       output = subprocess.Popen([conf['cmd_fw'],'add'] + rule, stdout=subprocess.PIPE).communicate()[0]
    234       itemdb = ItemCache()
    235       itemdb.add(ipaddr)
    236       self.register(ipaddr)
    237       return True
    238     else:
    239       return False
    240 
    241   def register(self, ipaddr):
    242     epoch = int(time.time())
    243 
    244     itemdb = ItemCache()
    245     mac = itemdb.get_mac(ipaddr)
    246 
    247     filename = "/var/db/clients"
    248     file = open(filename,"a")
    249     file.write("%s %s %s \n" % (epoch, mac, ipaddr) )
    250     file.close()
    251 
    252 
    253   def delete(self, ipaddr):
    254     itemdb = ItemCache()
    255     itemdb.delete(ipaddr)
    256     self.cleanup()
    257 
    258   def delete_all(self):
    259     itemdb = ItemCache()
    260     itemdb.delete_all()
    261     self.cleanup()
    262  
    263   def get_log(self):
    264     return self.logger
    265 
    266 
    267 
     147     
     148# Call from crontab
     149if sys.argv[1:]:
     150  if sys.argv[1] == 'cleanup':
     151    fw = PacketFilterControl()
     152    fw.cleanup()
     153    sys.exit(0)
     154
     155### BEGIN STANDALONE/CGI PARSING ###
     156#
    268157# Query String Dictionaries
    269158qs_post = None
    270159qs = None
    271160header = []
    272 
    273 # Hybrid Setup.
    274 # a) We are not wrapped around in a HTTP server, so this _is_ the
    275 #    HTTP server, so act like one.
    276161if not os.environ.has_key('REQUEST_METHOD'):
     162  # a) We are not wrapped around in a HTTP server, so this _is_ the
     163  #    HTTP server, so act like one.
    277164  class TimeoutException(Exception):
    278165    """ Helper for alarm signal handling"""
     
    313200 
    314201  # Capture Portal, make sure to redirect all to portal
    315   if hostname != conf['portalroot']:
     202  if hostname != cfg['portalroot']:
    316203    print "HTTP/1.1 302 Moved Temponary\r\n",
    317     print "Location: http://%(portalroot)s/\r\n" % conf,
     204    print "Location: http://%(portalroot)s/\r\n" % cfg,
    318205    sys.exit(0)
    319206 
     
    340227
    341228  remote_host = os.environ['REMOTE_ADDR']
     229#
     230### END STANDALONE/CGI PARSING ###
    342231
    343232
    344233# Helpers for HTML 'templates'
    345 content = conf.copy()
     234content = cfg.copy()
    346235content.update(extra_header='',tech_footer='',status_msg='')
    347236
     
    351240# This assumes that devices will re-connect if they are not able to connect
    352241# to their original host, as we do not preserve the original URI.
    353 ic = ItemCache()
    354 if conf['autologin'] or remote_host in conf['whitelist'] or ic.get_mac(remote_host) in conf['whitelist']:
     242remote_mac = get_mac(remote_host)
     243if cfg['autologin'] or remote_host in cfg['whitelist'] or remote_mac in cfg['whitelist']:
    355244  qs_post = { 'action' : 'login' }
    356245
    357246try:
     247  fw = PacketFilterControl()
     248 
    358249  # Put authenticate use and process response
    359250  if qs and qs.has_key('action'):
    360     if 'deleteall' in qs['action']:
    361       content['status_msg'] += "# [INFO] Deleting all entries\n"
    362       fw = FirewallControl()
    363       fw.delete_all()
    364       content['status_msg'] += fw.get_log()
     251    if 'flush' in qs['action']:
     252      retval = fw.flush()
     253      content['status_msg'] += "# [INFO] Deleted %s entries\n" % retval
    365254    elif 'update' in qs['action']:
    366255      tech_footer = "# [INFO] Update timestamp of all entries\n"
    367       fw = FirewallControl()
    368256      fw.update()
    369257      content['status_msg'] += fw.get_log()
    370258    elif 'cleanup' in qs['action']:
    371       content['status_msg'] += "# [INFO] Deleting all entries"
    372       fw = FirewallControl()
    373       fw.delete_all()
     259      retval = fw.cleanup(cfg['expire_time'])
     260      content['status_msg'] += "# [INFO] Deleted %s entries\n" % retval
    374261  elif qs_post and qs_post.has_key('action'):
    375262    if 'login' in qs_post['action']:
    376       fw = FirewallControl()
    377263      if fw.add(remote_host):
    378264        content['extra_header'] = "Refresh: %(refresh_delay)s; url=%(portal_url)s\r" % content
    379265        content['status_msg'] = "Sucessfully Logged In! || " +\
    380266        """ Will redirect you in %(refresh_delay)s seconds to <a href="%(portal_url)s">%(portal_url)s</a> """ % content
     267        log_registered_host(remote_mac, remote_host)
    381268      else:
    382269        content['status_msg'] = "ERROR! Already Logged On"
    383270    elif 'logout' in qs_post['action']:
    384       fw = FirewallControl()
    385271      fw.delete(remote_host)
    386272      content['status_msg'] = "Succesfully logged out!"
    387273
    388 except Exception,e:
     274except Exception, e:
    389275  content['tech_footer'] += traceback.format_exc()
    390   content['status_msg'] = e
     276  content['status_msg'] = "<div class='error'>Internal error!<pre>%s</pre></div>" % traceback.format_exc()
    391277  pass
    392278
     
    399285
    400286try:
    401   tmpl_file = conf['tmpl_autologin'] if conf['autologin'] else conf['tmpl_login']
     287  tmpl_file = cfg['tmpl_autologin'] if cfg['autologin'] else cfg['tmpl_login']
    402288  page = open(tmpl_file,'r').read()
    403289except IOError:
Note: See TracChangeset for help on using the changeset viewer.