Index: branches/releng-9.0/nanobsd/files/etc/crontab
===================================================================
--- branches/releng-9.0/nanobsd/files/etc/crontab	(revision 10418)
+++ branches/releng-9.0/nanobsd/files/etc/crontab	(revision 10419)
@@ -4,5 +4,5 @@
 #
 SHELL=/bin/sh
-PATH=/etc:/bin:/sbin:/usr/bin:/usr/sbin
+PATH=/etc:/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin
 HOME=/var/log
 #
@@ -33,2 +33,5 @@
 # Nagios checks
 */15	*	*	*	*	root	/usr/local/sbin/check-inet-alive
+#
+*/5	*	*	*	*	root	/usr/local/www/wlportal/index.cgi cleanup
+
Index: branches/releng-9.0/nanobsd/files/etc/pf.node.conf
===================================================================
--- branches/releng-9.0/nanobsd/files/etc/pf.node.conf	(revision 10419)
+++ branches/releng-9.0/nanobsd/files/etc/pf.node.conf	(revision 10419)
@@ -0,0 +1,18 @@
+# Table used to authorized hosts
+table <wlportal> persist counters
+
+# Always be nice, and return the fact we are blocking the packets
+set block-policy return
+
+# Localhost is considered safe and should thus not be filtered
+set skip on lo0
+
+wl_net="172.16.0.0/12"
+#captive_ifs="vr0,vr1,vr2,ue0"
+
+# Redirect user to captive portal they have not clicked OK yet.
+no rdr on { $captive_portal_interfaces } proto tcp from <wlportal> to !$wl_net port 80
+rdr on { $captive_portal_interfaces } proto tcp from $wl_net to !$wl_net port 80 -> 127.0.0.1 port 8082
+
+# Default allow
+pass all
Index: branches/releng-9.0/nanobsd/files/etc/rc.conf
===================================================================
--- branches/releng-9.0/nanobsd/files/etc/rc.conf	(revision 10418)
+++ branches/releng-9.0/nanobsd/files/etc/rc.conf	(revision 10419)
@@ -1,6 +1,10 @@
 # No kernel dumps as we don't have a place to store them 
 dumpdev="NO"			
-# We are an router/gateway (wireless to be precise)
+
+# We are an router/gateway (wireless to be precise) running the lvrouted
+# routing daemon.
 gateway_enable="YES"
+lvrouted_enable="YES"
+lvrouted_flags="-u -s s00p3rs3kr3t -m 28"
 
 # NTP server needs working config with WL network or internet on boot
@@ -30,5 +34,5 @@
 
 # HTTP(S) proxy server
-tinyproxy_enable="YES"
+tinyproxy_enable="NO"
 
 # Make sure generated ssh keys are saved 
@@ -54,2 +58,7 @@
 pf_enable="YES"
 pf_rules="/etc/pf.open.conf"
+pf_flags=''
+# Used with /etc/pf.proxy.conf
+# pf_flags="-D ext_if=vr0 -D int_if=vr1 -D publicnat={80,443}"
+# Used with /etc/pf.node.conf
+# pf_flags="-D captive_portal_interfaces=wlan0,wlan1"
Index: branches/releng-9.0/nanobsd/files/usr/local/www/wlportal/index.cgi
===================================================================
--- branches/releng-9.0/nanobsd/files/usr/local/www/wlportal/index.cgi	(revision 10418)
+++ branches/releng-9.0/nanobsd/files/usr/local/www/wlportal/index.cgi	(revision 10419)
@@ -8,12 +8,12 @@
 # = 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
+# 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
-# 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
+# pfctl to work properly:
+#   no rdr on { $captive_ifs } proto tcp from <wlportal> 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)
@@ -26,16 +26,10 @@
 # get added during boot and will never disappear.
 #
-# The program has uses a file based persistent cache to save authenticated
-# ACLs, this will NOT get synced after a reboot. 
-#
-# State   : ALPHA 
+# State   : v0.6.0
 # Version : $Id$
 # Author  : Rick van der Zwet <info@rickvanderzwet.nl>
 # Licence : BSDLike http://wirelessleiden.nl/LICENSE
 
-import logging
 import os
-import pickle
-import re
 import signal
 import subprocess
@@ -47,8 +41,8 @@
 
 # XXX: Make me dynamic for example put me in the conf file
-conf = { 
+cfg = { 
   'autologin'     : False,
   'cmd_arp'       : '/usr/sbin/arp',
-  'cmd_fw'        : '/sbin/ipfw',
+  'pfctl'         : '/sbin/pfctl',
   'portal_sponsor': 'Sponsor van Stichting Wireless Leiden',
   'portal_url'    : 'http://www.wirelessleiden.nl',
@@ -58,221 +52,114 @@
   'tmpl_login'    : '/usr/local/etc/wlportal/login.tmpl',
   'whitelist'     : [],
+  'config_file'   : '/usr/local/etc/wlportal/config.yaml',
+  'expire_time'   : None,
+  'accessdb'      : '/var/db/clients',
 }
 
-
-logging.basicConfig(stream=open('/var/log/wlportal.log','a'),level=logging.DEBUG)
 
 # 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
-# XXX: 5xx error code perhaps?
-try:
-  conf.update(yaml.load(open('/usr/local/etc/wlportal/config.yaml')))
-except Exception,e:
-  logging.error(traceback.format_exc())
-
-
-class ItemCache:
-  """ 
-  Very basic ItemCache used for caching registered entries and other foo, no
-  way recurrent, so use with care!
+if os.path.isfile(cfg['config_file']):
+  cfg.update(yaml.load(open(cfg['config_file'])))
+
+def log_registered_host(remote_mac, remote_host):
   """
-
-  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([conf['cmd_arp'],'-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 get_mac(self,ipaddr):
-    self.load_arp_cache()
-    try:
-      return self.arp_cache[ipaddr]
-    except KeyError:
-      return None
-
-  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
+   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:
+    return subprocess.check_output(['/usr/sbin/arp', '-n' ,ipaddr], shell=False).split()[3][1:-1]
+  except subprocess.CalledProcessError:
+    raise MACnotFound
+
+def get_active_MACs():
+  """ Return dictionary with active IPs as keys """
+  output = subprocess.check_output(['/usr/sbin/arp', '-n' ,'-a'], shell=False)
+  db = {}
+  for line in output.strip().split('\n'):
+    i = line.split()
+    mac = i[3][1:-1]
+    ip = i[5]
+    db[ip] = mac
+  return db
     
-    # 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([conf['cmd_fw'],'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 dst-port 80,443
-      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()
+
+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 stored_mac.has_key(ip) and active_mac[ip] == stored_mac[ip]:
+        # previous record found
+        # Stored v.s. Active happy
+        continue
+      else:
+        self.delete(ip)
+    	deleted_entries =+ 1 
+    return deleted_entries
       
-    # 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([conf['cmd_fw'],'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 dst-port 80,443".split()
-      rule[0] = str(self.available_rule)
-      rule[4] = str(ipaddr)
-      logging.debug("Addding %s" % " ".join(rule))
-      output = subprocess.Popen([conf['cmd_fw'],'add'] + rule, stdout=subprocess.PIPE).communicate()[0]
-      itemdb = ItemCache()
-      itemdb.add(ipaddr)
-      self.register(ipaddr)
-      return True
-    else:
-      return False
-
-  def register(self, ipaddr):
-    epoch = int(time.time())
-
-    itemdb = ItemCache()
-    mac = itemdb.get_mac(ipaddr)
-
-    filename = "/var/db/clients" 
-    file = open(filename,"a")
-    file.write("%s %s %s \n" % (epoch, mac, ipaddr) )
-    file.close()
-
-
-  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
-
-
-
+      
+# 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 = []
-
-# Hybrid Setup.
-# a) We are not wrapped around in a HTTP server, so this _is_ the
-#    HTTP server, so act like one.
 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"""
@@ -313,7 +200,7 @@
   
   # Capture Portal, make sure to redirect all to portal
-  if hostname != conf['portalroot']:
+  if hostname != cfg['portalroot']:
     print "HTTP/1.1 302 Moved Temponary\r\n",
-    print "Location: http://%(portalroot)s/\r\n" % conf,
+    print "Location: http://%(portalroot)s/\r\n" % cfg,
     sys.exit(0)
   
@@ -340,8 +227,10 @@
 
   remote_host = os.environ['REMOTE_ADDR']
+#
+### END STANDALONE/CGI PARSING ###
 
 
 # Helpers for HTML 'templates'
-content = conf.copy()
+content = cfg.copy()
 content.update(extra_header='',tech_footer='',status_msg='')
 
@@ -351,42 +240,39 @@
 # 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.
-ic = ItemCache()
-if conf['autologin'] or remote_host in conf['whitelist'] or ic.get_mac(remote_host) in conf['whitelist']:
+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' }
 
 try:
+  fw = PacketFilterControl()
+  
   # Put authenticate use and process response
   if qs and qs.has_key('action'):
-    if 'deleteall' in qs['action']:
-      content['status_msg'] += "# [INFO] Deleting all entries\n"
-      fw = FirewallControl()
-      fw.delete_all()
-      content['status_msg'] += fw.get_log()
+    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 = FirewallControl()
       fw.update()
       content['status_msg'] += fw.get_log()
     elif 'cleanup' in qs['action']:
-      content['status_msg'] += "# [INFO] Deleting all entries"
-      fw = FirewallControl()
-      fw.delete_all()
+      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']:
-      fw = FirewallControl()
       if 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 <a href="%(portal_url)s">%(portal_url)s</a> """ % content
+        log_registered_host(remote_mac, remote_host)
       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!"
 
-except Exception,e:
+except Exception, e:
   content['tech_footer'] += traceback.format_exc()
-  content['status_msg'] = e
+  content['status_msg'] = "<div class='error'>Internal error!<pre>%s</pre></div>" % traceback.format_exc()
   pass
 
@@ -399,5 +285,5 @@
 
 try:
-  tmpl_file = conf['tmpl_autologin'] if conf['autologin'] else conf['tmpl_login']
+  tmpl_file = cfg['tmpl_autologin'] if cfg['autologin'] else cfg['tmpl_login']
   page = open(tmpl_file,'r').read()
 except IOError:
