Merge "bouncer: a bouncer for commmon room WiFi"
diff --git a/Makefile b/Makefile
index 9a3c478..cdc600d 100644
--- a/Makefile
+++ b/Makefile
@@ -15,11 +15,12 @@
BUILD_CRYPTDEV?= # default off: needs libdevmapper
BUILD_SIGNING?= # default off: needs libgtest
BUILD_JSONPOLL?=n
+BUILD_BOUNCER?= # default off: costly
BUILD_PRESTERASTATS?=n
export BUILD_HNVRAM BUILD_SSDP BUILD_DNSSD BUILD_LOGUPLOAD \
BUILD_BLUETOOTH BUILD_WAVEGUIDE BUILD_DVBUTILS BUILD_SYSMGR \
BUILD_STATUTILS BUILD_CRYPTDEV BUILD_SIGNING BUILD_JSONPOLL \
- BUILD_PRESTERASTATS BUILD_CACHE_WARMING
+ BUILD_PRESTERASTATS BUILD_CACHE_WARMING BUILD_BOUNCER
# note: libgpio is not built here. It's conditionally built
# via buildroot/packages/google/google_platform/google_platform.mk
@@ -73,6 +74,10 @@
DIRS+=craftui
endif
+ifeq ($(BUILD_BOUNCER),y)
+DIRS+=bouncer
+endif
+
ifeq ($(BR2_TARGET_GENERIC_PLATFORM_NAME),gfsc100)
DIRS+=diags
endif
diff --git a/bouncer/.gitignore b/bouncer/.gitignore
new file mode 100644
index 0000000..d82d8f5
--- /dev/null
+++ b/bouncer/.gitignore
@@ -0,0 +1,4 @@
+authorizer
+host-authorizer
+http_bouncer
+host-http_bouncer
diff --git a/bouncer/Makefile b/bouncer/Makefile
new file mode 100644
index 0000000..f579e10
--- /dev/null
+++ b/bouncer/Makefile
@@ -0,0 +1,49 @@
+default: all
+
+BINDIR=$(DESTDIR)/bin
+GPYLINT=$(shell \
+ if which gpylint >/dev/null; then \
+ echo gpylint; \
+ else \
+ echo 'echo "(gpylint-missing)" >&2'; \
+ fi \
+)
+
+TARGETS=authorizer http_bouncer
+
+HOST_TARGETS=$(addprefix host-,$(TARGETS))
+
+all: $(TARGETS) $(HOST_TARGETS)
+
+install:
+ mkdir -p $(BINDIR)
+ cp $(TARGETS) $(BINDIR)
+
+install-libs:
+ @echo "No libs to install."
+
+%: %.py
+ ln -s $< $@
+
+host-%: %.py
+ ln -s $< $@
+
+TESTS = $(wildcard test-*.sh) $(wildcard test-*.py) $(wildcard *_test.py)
+runtests: all $(TESTS)
+ set -e; \
+ for d in $(TESTS); do \
+ echo Running $$d; \
+ ./$$d; \
+ done
+
+lint: $(filter-out options.py,$(wildcard *.py))
+ $(GPYLINT) $^
+
+test: all $(TESTS)
+ ./wvtest/wvtestrun $(MAKE) runtests
+
+clean:
+ rm -f *.o $(TARGETS) \
+ $(HOST_TARGETS) \
+ *~ .*~ */*.pyc test_file *.pb.* *.tmp.*
+ rm -rf test_dir
diff --git a/bouncer/authorizer.py b/bouncer/authorizer.py
new file mode 100755
index 0000000..54c1fd2
--- /dev/null
+++ b/bouncer/authorizer.py
@@ -0,0 +1,156 @@
+#!/usr/bin/python
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""authorizer: processes Terms of Service acceptance for users."""
+
+import logging
+import re
+import subprocess
+import sys
+import time
+
+import options
+import tornado.escape
+import tornado.httpclient
+import tornado.ioloop
+import tornado.netutil
+
+
+optspec = """
+authorizer [options...]
+--
+c,filter-chain= iptables chain to operate on [captive-portal-guests]
+C,ca-certs= path to CA certificates [/etc/ssl/certs/ca-certificates.crt]
+d,dry-run don't modify iptables
+m,max-age= oldest acceptance to consider as valid, in days [60]
+n,nat-chain= iptables NAT chain to operate on [captive-portal-guests-nat]
+U,unix-path= Unix socket to listen on [/tmp/authorizer.sock]
+u,url= URL to query for authentication [https://fiber-managed-wifi-tos.appspot.com/tos-accepted?id=%(mac)s]
+"""
+
+MAX_TRIES = 300
+
+known_users = {}
+
+
+def ip46tables(*args):
+ if opt.dry_run:
+ return 0
+
+ x = subprocess.call(['iptables'] + list(args))
+ y = subprocess.call(['ip6tables'] + list(args))
+ return x | y
+
+
+class Checker(object):
+ """Manage checking and polling for Terms of Service acceptance."""
+
+ def __init__(self, mac_addr, url):
+ self.mac_addr = mac_addr
+ self.url = url % {'mac': mac_addr}
+ self.tries = 0
+ self.callback = None
+
+ def check(self):
+ """Check if a remote service knows about a device with a supplied MAC."""
+ logging.info('Checking TOS for %s', self.mac_addr)
+ http_client = tornado.httpclient.HTTPClient()
+ response = http_client.fetch(self.url, ca_certs=opt.ca_certs)
+ response_obj = tornado.escape.json_decode(response.body)
+ accepted_time = response_obj.get('accepted')
+ self.tries += 1
+
+ accepted = False
+ if accepted_time:
+ if accepted_time + (opt.max_age * 86400) > time.time():
+ accepted = True
+ if self.callback: self.callback.stop()
+ logging.info('TOS accepted for %s', self.mac_addr)
+
+ known_users[self.mac_addr] = response_obj
+ result = ip46tables('-A', opt.filter_chain, '-m', 'mac',
+ '--mac-source', self.mac_addr, '-j', 'ACCEPT')
+ result |= ip46tables('-t', 'nat', '-A', opt.nat_chain, '-m', 'mac',
+ '--mac-source', self.mac_addr, '-j', 'ACCEPT')
+ if result:
+ logging.error('Could not update firewall for device %s',
+ self.mac_addr)
+ else:
+ logging.info('TOS accepted too long ago for %s: %r', self.mac_addr,
+ accepted_time)
+
+ elif self.callback and self.tries > MAX_TRIES:
+ if not accepted:
+ logging.info('TOS not accepted for %s before timeout.', self.mac_addr)
+ self.callback.stop()
+
+ return response, accepted
+
+ def poll(self):
+ self.callback = tornado.ioloop.PeriodicCallback(self.check, 1000)
+ self.callback.start()
+
+
+def accept(connection, unused_address):
+ """Accept a MAC address and find out if it's authorized."""
+ cf = connection.makefile()
+
+ maybe_mac_addr = cf.readline().strip()
+ if re.match('([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$', maybe_mac_addr):
+ mac_addr = maybe_mac_addr.lower()
+ else:
+ logging.warning('can only check authorization for a MAC address.')
+ cf.write('{}')
+ return
+
+ if mac_addr in known_users:
+ logging.info('TOS accepted (cached) for %s', mac_addr)
+ cached_response = known_users[mac_addr]
+ cached_response['cached'] = True
+ cf.write(tornado.escape.json_encode(cached_response))
+ return
+
+ checker = Checker(mac_addr, opt.url)
+ response, accepted = checker.check()
+ if not accepted:
+ checker.poll()
+
+ cf.write(response.body)
+
+
+if __name__ == '__main__':
+ o = options.Options(optspec)
+ opt, flags, extra = o.parse(sys.argv[1:])
+
+ if not opt.unix_path:
+ o.fatal('unix-path is required\n')
+
+ if not (opt.filter_chain and opt.nat_chain) and not opt.dry_run:
+ o.fatal('(filter-chain and nat-chain) or dry-run is required\n')
+
+ # work whether or not Tornado has configured the root logger already
+ logging.basicConfig(level=logging.INFO)
+ logging.getLogger().setLevel(logging.INFO)
+
+ ip46tables('-F', opt.filter_chain)
+ ip46tables('-t', 'nat', '-F', opt.nat_chain)
+
+ sock = tornado.netutil.bind_unix_socket(opt.unix_path)
+ ioloop = tornado.ioloop.IOLoop.instance()
+ tornado.netutil.add_accept_handler(sock, accept, ioloop)
+
+ logging.info('Started authorizer.')
+ ioloop.start()
+
diff --git a/bouncer/http_bouncer.py b/bouncer/http_bouncer.py
new file mode 100755
index 0000000..3e9e138
--- /dev/null
+++ b/bouncer/http_bouncer.py
@@ -0,0 +1,118 @@
+#!/usr/bin/python
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""Redirects all HTTP requests to the specified URL."""
+
+import logging
+import socket
+import subprocess
+import sys
+import urllib2
+
+import options
+import tornado.httpclient
+import tornado.ioloop
+import tornado.web
+
+
+optspec = """
+http_bouncer [options...]
+--
+p,port= TCP port to listen on [8888]
+u,url= URL to redirect ("bounce") users to. Include the format specifier %(mac)s to write the users' MAC address into the URL when bouncing. []
+U,unix-path= Unix socket to use for authorization checking [/tmp/authorizer.sock]
+"""
+
+PKI_HOSTS = set(['pki.google.com', 'clients1.google.com'])
+
+
+def mac_for_ip(remote_ip):
+ arp_response = subprocess.check_output(['arp', remote_ip])
+ return arp_response.split()[3]
+
+
+class Redirector(tornado.web.RequestHandler):
+ """Redirect users' HTTP connections to a captive portal landing page."""
+
+ def initialize(self, substitute_mac):
+ self.substitute_mac = substitute_mac
+ self._http_client = tornado.httpclient.HTTPClient()
+
+ def get(self):
+ if self._is_crl_request():
+ # proxy CRL/OCSP requests. Workaround for b/19825798.
+ url = '%s://%s%s' % (self.request.protocol, self.request.host,
+ self.request.uri)
+ logging.info('Forwarding request to %s', url)
+ response = self._http_client.fetch(url)
+ for (name, value) in response.headers.get_all():
+ self.set_header(name, value)
+
+ if response.body:
+ self.set_header('Content-Length', len(response.body))
+ self.write(response.body)
+ else:
+ if self.substitute_mac:
+ mac = mac_for_ip(self.request.remote_ip)
+ self.redirect(opt.url % {'mac': mac})
+
+ if opt.unix_path:
+ try:
+ s = socket.socket(socket.AF_UNIX)
+ s.connect(opt.unix_path)
+ s.sendall('%s\n' % mac)
+ s.close()
+ except socket.error:
+ logging.warning('Could not contact authorizer.')
+ else:
+ self.redirect(opt.url)
+
+ def _is_crl_request(self):
+ uri = self.request.uri
+ return self.request.host in PKI_HOSTS and (uri.startswith('/ocsp/')
+ or uri.endswith('.crl'))
+
+if __name__ == '__main__':
+ o = options.Options(optspec)
+ opt, flags, extra = o.parse(sys.argv[1:])
+
+ if not opt.port or not opt.url:
+ o.fatal('port and url are required\n')
+
+ # work whether or not Tornado has configured the root logger already
+ logging.basicConfig(level=logging.INFO)
+ logging.getLogger().setLevel(logging.INFO)
+
+ try:
+ formatted_url = opt.url % {'mac': '00:00:00:00:00:00'}
+ urllib2.urlopen(formatted_url).getcode()
+ except (TypeError, ValueError, urllib2.URLError):
+ o.fatal('url must be a URL.')
+
+ url_needs_mac = formatted_url != opt.url
+ if url_needs_mac and not opt.unix_path:
+ o.fatal('unix-path missing but URL requested MAC-based authorization')
+
+ application = tornado.web.Application([
+ (r'.*', Redirector, dict(substitute_mac=url_needs_mac)),
+ ])
+
+ try:
+ application.listen(opt.port)
+ except socket.gaierror:
+ o.fatal('port must be a TCP port, and we must be able to bind it.')
+
+ logging.info('Starting http_bouncer.')
+ tornado.ioloop.IOLoop.instance().start()
diff --git a/bouncer/options.py b/bouncer/options.py
new file mode 120000
index 0000000..3508154
--- /dev/null
+++ b/bouncer/options.py
@@ -0,0 +1 @@
+../options.py
\ No newline at end of file
diff --git a/bouncer/test-http_bouncer.sh b/bouncer/test-http_bouncer.sh
new file mode 100755
index 0000000..dedd742
--- /dev/null
+++ b/bouncer/test-http_bouncer.sh
@@ -0,0 +1,55 @@
+#!/bin/bash
+#
+# Copyright 2015 Google Inc. All Rights Reserved.
+#
+
+. ./wvtest/wvtest.sh
+
+HTTP_BOUNCER=./host-http_bouncer
+PORT="1337"
+URL="http://example.com"
+
+# command substition strips off trailing newlines, so we add a one-character
+# sentinel to the command's output
+SENTINEL="X"
+
+function run_http_bouncer() {
+ $HTTP_BOUNCER -u $URL -p $PORT &
+ pid=$!
+ trap 'kill $pid' EXIT
+}
+
+function wait_for_socket() {
+ i=0
+ retries=100
+ while ! nc -z localhost $PORT && [ $i -lt $retries ] ; do sleep 0.1; i=$(expr $i + 1); done
+}
+
+WVSTART "http_bouncer test"
+
+# fail with no arguments
+WVFAIL $HTTP_BOUNCER
+# fail with extra arguments
+WVFAIL $HTTP_BOUNCER -u $URL -p $PORT --EXTRA_ARGUMENT
+# fail with invalid port
+WVFAIL $HTTP_BOUNCER -p $URL -u $PORT
+
+run_http_bouncer
+wait_for_socket
+
+redirect0=$(printf "< HTTP/1.0 302 Found\r\n< Location: $URL\r")
+redirect1=$(printf "< HTTP/1.1 302 Found\r\n< Location: $URL\r")
+
+WVPASSEQ "$(curl -0vH 'Host: google.com' "localhost:$PORT" 2>&1 |\
+ egrep '< HTTP|< Location')" "$redirect0"
+
+WVPASSEQ "$(curl -vH 'Host: google.com' "localhost:$PORT/path?arg" 2>&1 |\
+ egrep '< HTTP|< Location')" "$redirect1"
+
+WVPASSEQ "$(curl -0vH '' "localhost:$PORT" 2>&1 |\
+ egrep '< HTTP|< Location')" "$redirect0"
+
+# Make sure we can download a CRL even through the bouncer.
+# Some Internet Explorer versions will refuse to connect if we can't.
+WVPASS curl -H 'Host: pki.google.com' 'http://localhost:1337/GIAG2.crl' |\
+ openssl crl -inform DER
diff --git a/bouncer/wvtest b/bouncer/wvtest
new file mode 120000
index 0000000..75927a5
--- /dev/null
+++ b/bouncer/wvtest
@@ -0,0 +1 @@
+../cmds/wvtest
\ No newline at end of file