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