blob: 0c5e0b1b1874a6af2a63396ae3c87f150f890632 [file] [log] [blame]
#!/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 subprocess
import sys
import time
import hash_mac_addr
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
in_progress_users = {}
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
def is_valid_acceptance(response_obj):
accepted_time = response_obj.get('accepted')
return accepted_time + (opt.max_age * 86400) > time.time()
def allow_mac_rule(mac_addr):
# iptables, unlike other Linux utilities, capitalizes MAC addresses
return ('-m', 'mac', '--mac-source', mac_addr.upper(), '-j', 'ACCEPT')
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': hash_mac_addr.hash_mac_addr(self.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()
self.tries += 1
try:
response = http_client.fetch(self.url, ca_certs=opt.ca_certs)
response_obj = tornado.escape.json_decode(response.body)
valid = is_valid_acceptance(response_obj)
except tornado.httpclient.HTTPError as e:
logging.warning('Error checking authorization: %r', e)
valid = False
if valid:
logging.info('TOS accepted for %s', self.mac_addr)
known_users[self.mac_addr] = response_obj
result = ip46tables('-A', opt.filter_chain,
*allow_mac_rule(self.mac_addr))
result |= ip46tables('-t', 'nat', '-A', opt.nat_chain,
*allow_mac_rule(self.mac_addr))
if result:
logging.error('Could not update firewall for device %s',
self.mac_addr)
if valid or self.tries > MAX_TRIES:
if self.callback:
self.callback.stop()
if self.mac_addr in in_progress_users:
del in_progress_users[self.mac_addr]
else:
in_progress_users[self.mac_addr] = self
self.poll()
return response
def poll(self):
if not self.callback:
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()
try:
mac_addr = hash_mac_addr.normalize_mac_addr(maybe_mac_addr)
except ValueError:
logging.warning('can only check authorization for a MAC address.')
cf.write('{}')
return
if mac_addr in known_users:
cached_response = known_users[mac_addr]
if is_valid_acceptance(cached_response):
logging.info('TOS accepted (cached) for %s', mac_addr)
cached_response['cached'] = True
cf.write(tornado.escape.json_encode(cached_response))
return
if mac_addr in in_progress_users:
checker = in_progress_users[mac_addr]
else:
checker = Checker(mac_addr, opt.url)
response = checker.check()
cf.write(response.body)
def expire_cache():
"""Remove users whose authorization has expired from the cache."""
expired_users = set(mac_addr for mac_addr, cached_response
in known_users.items()
if not is_valid_acceptance(cached_response))
for mac_addr in expired_users:
logging.info('Removing expired user %s', mac_addr)
del known_users[mac_addr]
result = ip46tables('-D', opt.filter_chain, *allow_mac_rule(mac_addr))
result |= ip46tables('-t', 'nat', '-D', opt.nat_chain,
*allow_mac_rule(mac_addr))
if result:
logging.warning('Error removing expired user %s !', mac_addr)
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()
expirer = tornado.ioloop.PeriodicCallback(expire_cache, 60 * 60 * 1000)
expirer.start()