| 1 | #!/usr/bin/env python
|
|---|
| 2 | #
|
|---|
| 3 | # Wrap me around tcpserver or inetd, example usage for tcpserver (debug):
|
|---|
| 4 | # tcpserver -HRl localhost 172.31.255.1 /root/wlportal.py
|
|---|
| 5 | #
|
|---|
| 6 | # Or put me in a CGI script in for example thttpd server:
|
|---|
| 7 | #
|
|---|
| 8 | # = Usage =
|
|---|
| 9 | # This is a wrapper script which does very basic HTML parsing and altering of
|
|---|
| 10 | # pfctl tables rules to build a basic Captive Portal, with basic sanity
|
|---|
| 11 | # checking. The ACL is IP based (this is a poor mans solution, layer2
|
|---|
| 12 | # ACL would be much better), so don't take security very seriously.
|
|---|
| 13 | #
|
|---|
| 14 | # To get traffic by default to the portal iI requires a few special rules in
|
|---|
| 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
|
|---|
| 18 | #
|
|---|
| 19 | # Enties older than 5 minutes not being used will be removed if the (hidden)
|
|---|
| 20 | # argument action=cleanup is given as GET variable. So having this in cron (would fix it):
|
|---|
| 21 | # */5 * * * * /usr/bin/fetch -q http://172.31.255.1/wlportal?action=cleanup
|
|---|
| 22 | #
|
|---|
| 23 | # XXX: The whitelist entries first needs to contact the wlportal.py to get
|
|---|
| 24 | # added to the whitelist, this may cause issues during initial setup and hence
|
|---|
| 25 | # it might be advised to create a block of static whitelist IP addresses which
|
|---|
| 26 | # get added during boot and will never disappear.
|
|---|
| 27 | #
|
|---|
| 28 | # State : v0.6.0
|
|---|
| 29 | # Version : $Id: index.cgi 10724 2012-05-08 18:06:34Z rick $
|
|---|
| 30 | # Author : Rick van der Zwet <info@rickvanderzwet.nl>
|
|---|
| 31 | # Licence : BSDLike http://wirelessleiden.nl/LICENSE
|
|---|
| 32 | import cgitb
|
|---|
| 33 | cgitb.enable(logdir="/tmp")
|
|---|
| 34 |
|
|---|
| 35 |
|
|---|
| 36 | import os
|
|---|
| 37 | import signal
|
|---|
| 38 | import subprocess
|
|---|
| 39 | import socket
|
|---|
| 40 | import sys
|
|---|
| 41 | import time
|
|---|
| 42 | import traceback
|
|---|
| 43 | import urlparse
|
|---|
| 44 | import yaml
|
|---|
| 45 |
|
|---|
| 46 | from jinja2 import Template
|
|---|
| 47 |
|
|---|
| 48 | # XXX: Make me dynamic for example put me in the conf file
|
|---|
| 49 | cfg = {
|
|---|
| 50 | 'autologin' : False,
|
|---|
| 51 | 'cmd_arp' : '/usr/sbin/arp',
|
|---|
| 52 | 'pfctl' : '/sbin/pfctl',
|
|---|
| 53 | 'portal_sponsor': 'Sponsor van Stichting Wireless Leiden',
|
|---|
| 54 | 'portal_url' : 'http://wirelessleiden.nl/welkom?connected_to=%s' % socket.gethostname(),
|
|---|
| 55 | 'portalroot' : '172.31.255.1',
|
|---|
| 56 | 'refresh_delay' : 3,
|
|---|
| 57 | 'tmpl_autologin': '/usr/local/etc/wlportal/autologin.tmpl',
|
|---|
| 58 | 'tmpl_login' : '/usr/local/etc/wlportal/login.tmpl',
|
|---|
| 59 | 'whitelist' : [],
|
|---|
| 60 | 'config_file' : '/usr/local/etc/wlportal/config.yaml',
|
|---|
| 61 | 'expire_time' : None,
|
|---|
| 62 | 'accessdb' : '/var/db/clients',
|
|---|
| 63 | 'net_status' : '/tmp/network.status',
|
|---|
| 64 | }
|
|---|
| 65 |
|
|---|
| 66 |
|
|---|
| 67 | # No failback if config does not exist, to really make sure the user knows if
|
|---|
| 68 | # the config file failed to parse properly or is non-existing
|
|---|
| 69 | if os.path.isfile(cfg['config_file']):
|
|---|
| 70 | cfg.update(yaml.load(open(cfg['config_file'])))
|
|---|
| 71 |
|
|---|
| 72 | internet_up = True
|
|---|
| 73 | if os.path.isfile(cfg['net_status']):
|
|---|
| 74 | internet_up = 'internet=up' in open(cfg['net_status'], 'r').read().lower()
|
|---|
| 75 |
|
|---|
| 76 | if not internet_up:
|
|---|
| 77 | cfg['warning_msg'] = "<b>Internet Problemen</b>: 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"
|
|---|
| 78 |
|
|---|
| 79 | def log_registered_host(remote_mac, remote_host):
|
|---|
| 80 | """
|
|---|
| 81 | Write statistics file, used for (nagios) monitoring purposes
|
|---|
| 82 | """
|
|---|
| 83 | with open(cfg['accessdb'],"a") as fh:
|
|---|
| 84 | epoch = int(time.time())
|
|---|
| 85 | fh.write("%s %s %s \n" % (epoch, remote_mac, remote_host) )
|
|---|
| 86 |
|
|---|
| 87 | class MACnotFound(Exception):
|
|---|
| 88 | pass
|
|---|
| 89 |
|
|---|
| 90 | def get_mac(ipaddr):
|
|---|
| 91 | """ Find out the MAC for a certain IP address """
|
|---|
| 92 | try:
|
|---|
| 93 | return subprocess.check_output(['/usr/sbin/arp', '-n' ,ipaddr], shell=False).split()[3][1:-1]
|
|---|
| 94 | except subprocess.CalledProcessError:
|
|---|
| 95 | raise MACnotFound
|
|---|
| 96 |
|
|---|
| 97 | def get_active_MACs():
|
|---|
| 98 | """ Return dictionary with active IPs as keys """
|
|---|
| 99 | output = subprocess.check_output(['/usr/sbin/arp', '-n' ,'-a'], shell=False)
|
|---|
| 100 | db = {}
|
|---|
| 101 | for line in output.strip().split('\n'):
|
|---|
| 102 | i = line.split()
|
|---|
| 103 | mac = i[3][1:-1]
|
|---|
| 104 | ip = i[5]
|
|---|
| 105 | db[ip] = mac
|
|---|
| 106 | return db
|
|---|
| 107 |
|
|---|
| 108 |
|
|---|
| 109 | class PacketFilterControl():
|
|---|
| 110 | """ Manage an Packet Filter using pfctl and table wlportal"""
|
|---|
| 111 | def add(self, ipaddr):
|
|---|
| 112 | """ Add Allow Entry in Firewall"""
|
|---|
| 113 | output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'add', ipaddr], stderr=subprocess.PIPE).communicate()[1]
|
|---|
| 114 | is_added = '1/1 addresses added.' in output
|
|---|
| 115 | return is_added
|
|---|
| 116 | def delete(self, ipaddr):
|
|---|
| 117 | """ Delete one Allow Entry to Firewall"""
|
|---|
| 118 | output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'delete', ipaddr], stderr=subprocess.PIPE).communicate()[1]
|
|---|
| 119 | def flush(self):
|
|---|
| 120 | """ Delete all Allow Entries from Firewall"""
|
|---|
| 121 | output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'flush'], stderr=subprocess.PIPE).communicate()[1]
|
|---|
| 122 | #0 addresses deleted.
|
|---|
| 123 | return int(output.strip().split('\n')[-1].split()[0])
|
|---|
| 124 | def cleanup(self, expire_time=None):
|
|---|
| 125 | """ Delete obsolete entries and expired entries from the Firewall"""
|
|---|
| 126 | deleted_entries = 0
|
|---|
| 127 | # Delete entries older than certain time
|
|---|
| 128 | if expire_time:
|
|---|
| 129 | output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'expire', expire_time], stdout=subprocess.PIPE).communicate()[0]
|
|---|
| 130 | # 0/0 addresses expired.
|
|---|
| 131 | deleted_entries += int(output.strip.split()[-1].split('/')[0])
|
|---|
| 132 |
|
|---|
| 133 | # Delete entries which the MAC<>IP mapping does no longer hold. The
|
|---|
| 134 | # ``rogue'' clients, commonly seen when DHCP scope is small and IPs get
|
|---|
| 135 | # re-used frequently, are wipped and require an re-connect.
|
|---|
| 136 | stored_mac = {}
|
|---|
| 137 | if os.path.isfile(cfg['accessdb']):
|
|---|
| 138 | for line in open(cfg['accessdb'],'r'):
|
|---|
| 139 | (epoch, mac, ipaddr) = line.split()
|
|---|
| 140 | stored_mac[ipaddr] = mac
|
|---|
| 141 | # Live configuration
|
|---|
| 142 | active_mac = get_active_MACs()
|
|---|
| 143 | # Process all active ip addresses from firewall and compare changes
|
|---|
| 144 | output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'show'], stdout=subprocess.PIPE).communicate()[0]
|
|---|
| 145 | for ip in output.split():
|
|---|
| 146 | if ip in cfg['whitelist']:
|
|---|
| 147 | # IP is whitelisted
|
|---|
| 148 | continue
|
|---|
| 149 | elif active_mac.has_key(ip) and active_mac[ip] in cfg['whitelist']:
|
|---|
| 150 | # MAC is whitelisted
|
|---|
| 151 | continue
|
|---|
| 152 | elif stored_mac.has_key(ip) and active_mac[ip] == stored_mac[ip]:
|
|---|
| 153 | # previous record found
|
|---|
| 154 | # Stored v.s. Active happy
|
|---|
| 155 | continue
|
|---|
| 156 | else:
|
|---|
| 157 | self.delete(ip)
|
|---|
| 158 | deleted_entries =+ 1
|
|---|
| 159 | return deleted_entries
|
|---|
| 160 |
|
|---|
| 161 |
|
|---|
| 162 | # Call from crontab
|
|---|
| 163 | if sys.argv[1:]:
|
|---|
| 164 | if sys.argv[1] == 'cleanup':
|
|---|
| 165 | fw = PacketFilterControl()
|
|---|
| 166 | fw.cleanup()
|
|---|
| 167 | sys.exit(0)
|
|---|
| 168 |
|
|---|
| 169 | ### BEGIN STANDALONE/CGI PARSING ###
|
|---|
| 170 | #
|
|---|
| 171 | # Query String Dictionaries
|
|---|
| 172 | qs_post = None
|
|---|
| 173 | qs = None
|
|---|
| 174 | header = []
|
|---|
| 175 | if not os.environ.has_key('REQUEST_METHOD'):
|
|---|
| 176 | # a) We are not wrapped around in a HTTP server, so this _is_ the
|
|---|
| 177 | # HTTP server, so act like one.
|
|---|
| 178 | class TimeoutException(Exception):
|
|---|
| 179 | """ Helper for alarm signal handling"""
|
|---|
| 180 | pass
|
|---|
| 181 |
|
|---|
| 182 | def handler(signum, frame):
|
|---|
| 183 | """ Helper for alarm signal handling"""
|
|---|
| 184 | raise TimeoutException
|
|---|
| 185 |
|
|---|
| 186 |
|
|---|
| 187 | # Parse the HTTP/1.1 Content-Header (partially)
|
|---|
| 188 | signal.signal(signal.SIGALRM,handler)
|
|---|
| 189 | us = None
|
|---|
| 190 | method = None
|
|---|
| 191 | hostname = None
|
|---|
| 192 | content_length = None
|
|---|
| 193 | remote_host = None
|
|---|
| 194 | while True:
|
|---|
| 195 | try:
|
|---|
| 196 | signal.alarm(1)
|
|---|
| 197 | line = sys.stdin.readline().strip()
|
|---|
| 198 | if not line:
|
|---|
| 199 | break
|
|---|
| 200 | header.append(line)
|
|---|
| 201 | signal.alarm(0)
|
|---|
| 202 | if line.startswith('GET '):
|
|---|
| 203 | us = urlparse.urlsplit(line.split()[1])
|
|---|
| 204 | method = 'GET'
|
|---|
| 205 | elif line.startswith('POST '):
|
|---|
| 206 | method = 'POST'
|
|---|
| 207 | us = urlparse.urlsplit(line.split()[1])
|
|---|
| 208 | elif line.startswith('Host: '):
|
|---|
| 209 | hostname = line.split()[1]
|
|---|
| 210 | elif line.startswith('Content-Length: '):
|
|---|
| 211 | content_length = int(line.split()[1])
|
|---|
| 212 | except TimeoutException:
|
|---|
| 213 | break
|
|---|
| 214 |
|
|---|
| 215 | # Capture Portal, make sure to redirect all to portal
|
|---|
| 216 | if hostname != cfg['portalroot']:
|
|---|
| 217 | print "HTTP/1.1 302 Moved Temponary\r\n",
|
|---|
| 218 | print "Location: http://%(portalroot)s/\r\n" % cfg,
|
|---|
| 219 | sys.exit(0)
|
|---|
| 220 |
|
|---|
| 221 |
|
|---|
| 222 | # Handle potential POST
|
|---|
| 223 | if method == 'POST' and content_length:
|
|---|
| 224 | body = sys.stdin.read(content_length)
|
|---|
| 225 | qs_post = urlparse.parse_qs(body)
|
|---|
| 226 |
|
|---|
| 227 | # Parse Query String
|
|---|
| 228 | if us and us.path == "/wlportal" and us.query:
|
|---|
| 229 | qs = urlparse.parse_qs(us.query)
|
|---|
| 230 |
|
|---|
| 231 | remote_host = os.environ['REMOTEHOST']
|
|---|
| 232 | else:
|
|---|
| 233 | # b) CGI Script: Parse the CGI Variables if present
|
|---|
| 234 | if os.environ['REQUEST_METHOD'] == "POST":
|
|---|
| 235 | content_length = int(os.environ['CONTENT_LENGTH'])
|
|---|
| 236 | body = sys.stdin.read(content_length)
|
|---|
| 237 | qs_post = urlparse.parse_qs(body)
|
|---|
| 238 |
|
|---|
| 239 | if os.environ.has_key('QUERY_STRING'):
|
|---|
| 240 | qs = urlparse.parse_qs(os.environ['QUERY_STRING'])
|
|---|
| 241 |
|
|---|
| 242 | remote_host = os.environ['REMOTE_ADDR']
|
|---|
| 243 | #
|
|---|
| 244 | ### END STANDALONE/CGI PARSING ###
|
|---|
| 245 |
|
|---|
| 246 |
|
|---|
| 247 | # Helpers for HTML 'templates'
|
|---|
| 248 | content = cfg.copy()
|
|---|
| 249 | content.update(extra_header='')
|
|---|
| 250 |
|
|---|
| 251 | # IP or MAC on the whitelist does not need to authenticate, used for devices
|
|---|
| 252 | # which need to connect to the internet, but has no 'buttons' to press OK.
|
|---|
| 253 | #
|
|---|
| 254 | # This assumes that devices will re-connect if they are not able to connect
|
|---|
| 255 | # to their original host, as we do not preserve the original URI.
|
|---|
| 256 | remote_mac = get_mac(remote_host)
|
|---|
| 257 | if cfg['autologin'] or remote_host in cfg['whitelist'] or remote_mac in cfg['whitelist']:
|
|---|
| 258 | qs_post = { 'action' : 'login' }
|
|---|
| 259 |
|
|---|
| 260 | try:
|
|---|
| 261 | fw = PacketFilterControl()
|
|---|
| 262 |
|
|---|
| 263 | # Put authenticate use and process response
|
|---|
| 264 | if qs and qs.has_key('action'):
|
|---|
| 265 | if 'flush' in qs['action']:
|
|---|
| 266 | retval = fw.flush()
|
|---|
| 267 | content['status_msg'] += "# [INFO] Deleted %s entries\n" % retval
|
|---|
| 268 | elif 'update' in qs['action']:
|
|---|
| 269 | tech_footer = "# [INFO] Update timestamp of all entries\n"
|
|---|
| 270 | fw.update()
|
|---|
| 271 | content['status_msg'] += fw.get_log()
|
|---|
| 272 | elif 'cleanup' in qs['action']:
|
|---|
| 273 | retval = fw.cleanup(cfg['expire_time'])
|
|---|
| 274 | content['status_msg'] += "# [INFO] Deleted %s entries\n" % retval
|
|---|
| 275 | elif qs_post and qs_post.has_key('action'):
|
|---|
| 276 | if 'login' in qs_post['action']:
|
|---|
| 277 | if fw.add(remote_host):
|
|---|
| 278 | content['extra_header'] = "Refresh: %(refresh_delay)s; url=%(portal_url)s\r" % content
|
|---|
| 279 | content['status_msg'] = "Sucessfully Logged In! || " +\
|
|---|
| 280 | """ Will redirect you in %(refresh_delay)s seconds to <a href="%(portal_url)s">%(portal_url)s</a> """ % content
|
|---|
| 281 | log_registered_host(remote_mac, remote_host)
|
|---|
| 282 | else:
|
|---|
| 283 | content['status_msg'] = "ERROR! Already Logged On"
|
|---|
| 284 | elif 'logout' in qs_post['action']:
|
|---|
| 285 | fw.delete(remote_host)
|
|---|
| 286 | content['status_msg'] = "Succesfully logged out!"
|
|---|
| 287 |
|
|---|
| 288 | except Exception, e:
|
|---|
| 289 | content['tech_footer'] += traceback.format_exc()
|
|---|
| 290 | content['status_msg'] = "<div class='error'>Internal error!<pre>%s</pre></div>" % traceback.format_exc()
|
|---|
| 291 | pass
|
|---|
| 292 |
|
|---|
| 293 | # Present Main Screen
|
|---|
| 294 | print """\
|
|---|
| 295 | HTTP/1.1 200 OK\r
|
|---|
| 296 | Content-Type: text/html\r
|
|---|
| 297 | %(extra_header)s
|
|---|
| 298 | """ % content
|
|---|
| 299 |
|
|---|
| 300 | try:
|
|---|
| 301 | tmpl_file = cfg['tmpl_autologin'] if cfg['autologin'] else cfg['tmpl_login']
|
|---|
| 302 | page = open(tmpl_file,'r').read()
|
|---|
| 303 | except IOError:
|
|---|
| 304 | page = """
|
|---|
| 305 | <html><head></head><body>
|
|---|
| 306 | <h2>%(status_msg)s</h2>
|
|---|
| 307 |
|
|---|
| 308 | <h3>Wireless Leiden - Internet Portal</h3>
|
|---|
| 309 | <form action="http://%(portalroot)s/wlportal/" method="POST">
|
|---|
| 310 | <input name="action" type="hidden" value="login" />
|
|---|
| 311 | <input type="submit" value="OK, agreed" />
|
|---|
| 312 | </form>
|
|---|
| 313 |
|
|---|
| 314 | <h3>More options</h3>
|
|---|
| 315 | <form action="http://%(portalroot)s/wlportal/" method="POST">
|
|---|
| 316 | <input name="action" type="hidden" value="logout" />
|
|---|
| 317 | <input type="submit" value="Cancel and/or Logout" />
|
|---|
| 318 | </form>
|
|---|
| 319 | <hr /><em>Technical Details:</em><pre>
|
|---|
| 320 | %(tech_footer)s
|
|---|
| 321 | </pre>
|
|---|
| 322 | </body></html>
|
|---|
| 323 | """
|
|---|
| 324 |
|
|---|
| 325 | print Template(page).render(content)
|
|---|