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