source: hybrid/branches/releng-9.0/nanobsd/files/usr/local/www/wlportal/index.cgi@ 10805

Last change on this file since 10805 was 10805, checked in by rick, 13 years ago

Anders heten de allebei index.cgi in de syslog en dat is wat ongelukkig.

Related-To: nodefactory:ticket:161

  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 12.4 KB
Line 
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 10805 2012-05-12 19:37:20Z rick $
30# Author : Rick van der Zwet <info@rickvanderzwet.nl>
31# Licence : BSDLike http://wirelessleiden.nl/LICENSE
32
33class MultiTracebackHook:
34 """A hook to replace sys.excepthook that shows tracebacks in syslog & HTML (using cgitb)"""
35 def __init__(self, ident=None, enable=False):
36 self.ident = ident
37 if enable:
38 self.enable()
39
40 def __call__(self, etype, evalue, etb):
41 self.handle((etype, evalue, etb))
42
43 def handle(self, info=None):
44 import cgitb
45 import os
46 import sys
47 import syslog
48 import traceback
49 info = info or sys.exc_info()
50 tb = traceback.format_exception(*info)
51 if self.ident:
52 syslog.openlog(self.ident)
53 prefix = '[%i]' % os.getpid()
54 for line in tb:
55 syslog.syslog(line)
56 cgitb.handler(info)
57
58 def enable(self):
59 import sys
60 sys.excepthook = self
61
62MultiTracebackHook(ident='wlportal', enable=True)
63
64import os
65import signal
66import subprocess
67import socket
68import sys
69import time
70import traceback
71import urlparse
72import yaml
73
74from jinja2 import Template
75
76# XXX: Make me dynamic for example put me in the conf file
77cfg = {
78 'autologin' : False,
79 'cmd_arp' : '/usr/sbin/arp',
80 'pfctl' : '/sbin/pfctl',
81 'portal_sponsor': 'Sponsor van Stichting Wireless Leiden',
82 'portal_url' : 'http://wirelessleiden.nl/welkom?connected_to=%s' % socket.gethostname(),
83 'portalroot' : '172.31.255.1',
84 'refresh_delay' : 3,
85 'tmpl_autologin': '/usr/local/etc/wlportal/autologin.tmpl',
86 'tmpl_login' : '/usr/local/etc/wlportal/login.tmpl',
87 'whitelist' : [],
88 'config_files' : ['/usr/local/etc/wlportal/config.yaml','/etc/wleiden.yaml'],
89 'expire_time' : None,
90 'accessdb' : '/var/db/clients',
91 'net_status' : '/tmp/network.status',
92}
93
94
95# No failback if config does not exist, to really make sure the user knows if
96# the config file failed to parse properly or is non-existing
97for config_file in cfg['config_files']:
98 if os.path.isfile(config_file):
99 cfg.update(yaml.load(open(config_file)))
100
101internet_up = True
102if os.path.isfile(cfg['net_status']):
103 internet_up = 'internet=up' in open(cfg['net_status'], 'r').read().lower()
104
105if not internet_up:
106 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"
107
108def log_registered_host(remote_mac, remote_host):
109 """
110 Write statistics file, used for (nagios) monitoring purposes
111 """
112 with open(cfg['accessdb'],"a") as fh:
113 epoch = int(time.time())
114 fh.write("%s %s %s \n" % (epoch, remote_mac, remote_host) )
115
116class MACnotFound(Exception):
117 pass
118
119def get_mac(ipaddr):
120 """ Find out the MAC for a certain IP address """
121 try:
122 # ? (172.17.32.1) at 00:12:34:45:67:90 on ue0 permanent [ethernet]
123 return subprocess.check_output(['/usr/sbin/arp', '-n' ,ipaddr], shell=False).split()[3]
124 except subprocess.CalledProcessError:
125 raise MACnotFound
126
127def get_active_MACs():
128 """ Return dictionary with active IPs as keys """
129 # ? (172.17.32.1) at 00:12:34:45:67:90 on ue0 permanent [ethernet]
130 # ? (172.17.32.2) at 00:aa:bb:cc:dd:ee on ue0 expires in 964 seconds [ethernet]
131 # ? (172.16.3.38) at (incomplete) on vr2 expired [ethernet]
132 output = subprocess.check_output(['/usr/sbin/arp', '-n' ,'-a'], shell=False)
133 db = {}
134 for line in output.strip().split('\n'):
135 i = line.split()
136 ip = i[1][1:-1]
137 mac = i[3]
138 db[ip] = mac
139 return db
140
141
142class PacketFilterControl():
143 """ Manage an Packet Filter using pfctl and table wlportal"""
144 def add(self, ipaddr):
145 """ Add Allow Entry in Firewall"""
146 output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'add', ipaddr], stderr=subprocess.PIPE).communicate()[1]
147 is_added = '1/1 addresses added.' in output
148 return is_added
149 def delete(self, ipaddr):
150 """ Delete one Allow Entry to Firewall"""
151 output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'delete', ipaddr], stderr=subprocess.PIPE).communicate()[1]
152 def flush(self):
153 """ Delete all Allow Entries from Firewall"""
154 output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'flush'], stderr=subprocess.PIPE).communicate()[1]
155 #0 addresses deleted.
156 return int(output.strip().split('\n')[-1].split()[0])
157 def cleanup(self, expire_time=None):
158 """ Delete obsolete entries and expired entries from the Firewall"""
159 deleted_entries = 0
160 # Delete entries older than certain time
161 if expire_time:
162 output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'expire', expire_time], stdout=subprocess.PIPE).communicate()[0]
163 # 0/0 addresses expired.
164 deleted_entries += int(output.strip.split()[-1].split('/')[0])
165
166 # Delete entries which the MAC<>IP mapping does no longer hold. The
167 # ``rogue'' clients, commonly seen when DHCP scope is small and IPs get
168 # re-used frequently, are wipped and require an re-connect.
169 stored_mac = {}
170 if os.path.isfile(cfg['accessdb']):
171 for line in open(cfg['accessdb'],'r'):
172 (epoch, mac, ipaddr) = line.split()
173 stored_mac[ipaddr] = mac
174 # Live configuration
175 active_mac = get_active_MACs()
176 # Process all active ip addresses from firewall and compare changes
177 output = subprocess.Popen([cfg['pfctl'],'-t','wlportal', '-T', 'show'], stdout=subprocess.PIPE).communicate()[0]
178 for ip in output.split():
179 if ip in cfg['whitelist']:
180 # IP is whitelisted
181 continue
182 elif active_mac.has_key(ip) and active_mac[ip] in cfg['whitelist']:
183 # MAC is whitelisted
184 continue
185 elif not active_mac.has_key(ip) and stored_mac.has_key(ip):
186 # In-active connection - Keep entry with normal expire time, as user
187 # might come back (temponary disconnect).
188 continue
189 elif active_mac.has_key(ip) and stored_mac.has_key(ip) and active_mac[ip] == stored_mac[ip]:
190 # Active connection - previous record found - Stored v.s. Active happy
191 continue
192 else:
193 self.delete(ip)
194 deleted_entries =+ 1
195 return deleted_entries
196
197
198# Call from crontab
199if sys.argv[1:]:
200 if sys.argv[1] == 'cleanup':
201 fw = PacketFilterControl()
202 fw.cleanup()
203 sys.exit(0)
204
205### BEGIN STANDALONE/CGI PARSING ###
206#
207# Query String Dictionaries
208qs_post = None
209qs = None
210header = []
211if not os.environ.has_key('REQUEST_METHOD'):
212 # a) We are not wrapped around in a HTTP server, so this _is_ the
213 # HTTP server, so act like one.
214 class TimeoutException(Exception):
215 """ Helper for alarm signal handling"""
216 pass
217
218 def handler(signum, frame):
219 """ Helper for alarm signal handling"""
220 raise TimeoutException
221
222
223 # Parse the HTTP/1.1 Content-Header (partially)
224 signal.signal(signal.SIGALRM,handler)
225 us = None
226 method = None
227 hostname = None
228 content_length = None
229 remote_host = None
230 while True:
231 try:
232 signal.alarm(1)
233 line = sys.stdin.readline().strip()
234 if not line:
235 break
236 header.append(line)
237 signal.alarm(0)
238 if line.startswith('GET '):
239 us = urlparse.urlsplit(line.split()[1])
240 method = 'GET'
241 elif line.startswith('POST '):
242 method = 'POST'
243 us = urlparse.urlsplit(line.split()[1])
244 elif line.startswith('Host: '):
245 hostname = line.split()[1]
246 elif line.startswith('Content-Length: '):
247 content_length = int(line.split()[1])
248 except TimeoutException:
249 break
250
251 # Capture Portal, make sure to redirect all to portal
252 if hostname != cfg['portalroot']:
253 print "HTTP/1.1 302 Moved Temponary\r\n",
254 print "Location: http://%(portalroot)s/\r\n" % cfg,
255 sys.exit(0)
256
257
258 # Handle potential POST
259 if method == 'POST' and content_length:
260 body = sys.stdin.read(content_length)
261 qs_post = urlparse.parse_qs(body)
262
263 # Parse Query String
264 if us and us.path == "/wlportal" and us.query:
265 qs = urlparse.parse_qs(us.query)
266
267 remote_host = os.environ['REMOTEHOST']
268else:
269 # b) CGI Script: Parse the CGI Variables if present
270 if os.environ['REQUEST_METHOD'] == "POST":
271 content_length = int(os.environ['CONTENT_LENGTH'])
272 body = sys.stdin.read(content_length)
273 qs_post = urlparse.parse_qs(body)
274
275 if os.environ.has_key('QUERY_STRING'):
276 qs = urlparse.parse_qs(os.environ['QUERY_STRING'])
277
278 remote_host = os.environ['REMOTE_ADDR']
279#
280### END STANDALONE/CGI PARSING ###
281
282
283# Helpers for HTML 'templates'
284content = cfg.copy()
285content.update(extra_header='')
286
287# IP or MAC on the whitelist does not need to authenticate, used for devices
288# which need to connect to the internet, but has no 'buttons' to press OK.
289#
290# This assumes that devices will re-connect if they are not able to connect
291# to their original host, as we do not preserve the original URI.
292remote_mac = get_mac(remote_host)
293if cfg['autologin'] or remote_host in cfg['whitelist'] or remote_mac in cfg['whitelist']:
294 qs_post = { 'action' : 'login' }
295
296try:
297 fw = PacketFilterControl()
298
299 # Put authenticate use and process response
300 if qs and qs.has_key('action'):
301 if 'flush' in qs['action']:
302 retval = fw.flush()
303 content['status_msg'] += "# [INFO] Deleted %s entries\n" % retval
304 elif 'update' in qs['action']:
305 tech_footer = "# [INFO] Update timestamp of all entries\n"
306 fw.update()
307 content['status_msg'] += fw.get_log()
308 elif 'cleanup' in qs['action']:
309 retval = fw.cleanup(cfg['expire_time'])
310 content['status_msg'] += "# [INFO] Deleted %s entries\n" % retval
311 elif qs_post and qs_post.has_key('action'):
312 if 'login' in qs_post['action']:
313 if fw.add(remote_host):
314 content['extra_header'] = "Refresh: %(refresh_delay)s; url=%(portal_url)s\r" % content
315 content['status_msg'] = "Sucessfully Logged In! || " +\
316 """ Will redirect you in %(refresh_delay)s seconds to <a href="%(portal_url)s">%(portal_url)s</a> """ % content
317 log_registered_host(remote_mac, remote_host)
318 else:
319 content['status_msg'] = "ERROR! Already Logged On"
320 elif 'logout' in qs_post['action']:
321 fw.delete(remote_host)
322 content['status_msg'] = "Succesfully logged out!"
323
324except Exception, e:
325 content['tech_footer'] += traceback.format_exc()
326 content['status_msg'] = "<div class='error'>Internal error!<pre>%s</pre></div>" % traceback.format_exc()
327 pass
328
329 # Present Main Screen
330print """\
331HTTP/1.1 200 OK\r
332Content-Type: text/html\r
333%(extra_header)s
334""" % content
335
336try:
337 tmpl_file = cfg['tmpl_autologin'] if cfg['autologin'] else cfg['tmpl_login']
338 page = open(tmpl_file,'r').read()
339except IOError:
340 page = """
341<html><head></head><body>
342<h2>%(status_msg)s</h2>
343
344<h3>Wireless Leiden - Internet Portal</h3>
345<form action="http://%(portalroot)s/wlportal/" method="POST">
346<input name="action" type="hidden" value="login" />
347<input type="submit" value="OK, agreed" />
348</form>
349
350<h3>More options</h3>
351<form action="http://%(portalroot)s/wlportal/" method="POST">
352<input name="action" type="hidden" value="logout" />
353<input type="submit" value="Cancel and/or Logout" />
354</form>
355<hr /><em>Technical Details:</em><pre>
356%(tech_footer)s
357</pre>
358</body></html>
359"""
360
361print Template(page).render(content)
Note: See TracBrowser for help on using the repository browser.