| #!/usr/bin/env 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. |
| # |
| """Command-line client to start the captive portal.""" |
| |
| __author__ = 'willangley@google.com (Will Angley)' |
| |
| import fnmatch |
| import os |
| import subprocess |
| import sys |
| import urlparse |
| import google3 |
| import bup.options |
| import tr.helpers |
| |
| optspec = """ |
| captive_portal start [options...] |
| captive_portal restart [options...] |
| captive_portal stop |
| -- |
| a,allowed-ips= list of allowed IPs/subnets [] |
| A,authorizer-url= authorizer URL to query [] |
| d,dry-run don't modify iptables |
| e,extra-tls-hosts= list of extra allowed TLS hosts [] |
| p,port= port on which HTTP bouncer will listen [8888] |
| u,url= redirect URL [] |
| """ |
| |
| sniproxy_conf = """# -- captive_portal filtering config -- |
| resolver { |
| nameserver 127.0.0.1 |
| } |
| |
| listen 8443 { |
| protocol tls |
| table https_hosts |
| } |
| |
| table https_hosts { |
| %(tls_host_lines)s |
| } |
| """ |
| |
| RUNFILES_DIR = ['/tmp/run'] |
| SNIPROXY_CONFIG_FILE = ['/tmp/sniproxy.conf'] |
| |
| |
| def iptables(*args): |
| if opt.dry_run: |
| return 0 |
| return subprocess.call(['iptables'] + list(args)) |
| |
| |
| def ip6tables(*args): |
| if opt.dry_run: |
| return 0 |
| return subprocess.call(['ip6tables'] + list(args)) |
| |
| |
| def ip46tables(*args): |
| return iptables(*args) | ip6tables(*args) |
| |
| |
| def pidfile(name): |
| return os.path.join(RUNFILES_DIR[0], '%s.pid' % name) |
| |
| |
| def babysit(name, *args): |
| """Start a command under the babysitter.""" |
| baby = subprocess.Popen(('babysit', '60', 'startpid', pidfile(name), name) |
| + args) |
| |
| if baby.poll(): |
| print >>sys.stderr, '%s failed to start!' % name |
| return baby.returncode |
| |
| return 0 |
| |
| |
| def start_allowed_ips(): |
| """Configure iptables to allow traffic to whitelisted IP addresses.""" |
| code = 0 |
| |
| for dest in opt.allowed_ips.split(): |
| # IPv4 only because our version of TR-069 is too |
| code |= iptables('-t', 'filter', '-A', 'acs-captive-portal-filter', |
| '-d', dest, '-j', 'ACCEPT') |
| code |= iptables('-t', 'nat', '-A', 'acs-captive-portal-nat', '-d', dest, |
| '-j', 'ACCEPT') |
| |
| return code |
| |
| |
| def stop_iptables(): |
| iptables('-t', 'nat', '-F', 'acs-captive-portal-nat') |
| ip46tables('-t', 'filter', '-F', 'acs-captive-portal-input') |
| ip46tables('-t', 'filter', '-F', 'acs-captive-portal-filter') |
| |
| |
| def start_http_bouncer(): |
| """Start the HTTP bouncer and NAT outbound traffic through it.""" |
| code = babysit('http_bouncer', '-p', str(opt.port), '-u', opt.url) |
| |
| if code: |
| return code |
| |
| code |= ip46tables('-t', 'filter', '-A', 'acs-captive-portal-input', |
| '-p', 'tcp', '--dport', str(opt.port), '-j', 'ACCEPT') |
| # TODO(willangley): should this also work on IPv6? |
| code |= iptables('-t', 'nat', '-A', 'acs-captive-portal-nat', '-p', 'tcp', |
| '--dport', '80', '-j', 'REDIRECT', |
| '--to-ports', str(opt.port)) |
| return code |
| |
| |
| def stop_http_bouncer(): |
| subprocess.call(['killpid', pidfile('http_bouncer')]) |
| |
| |
| def start_authorizer(): |
| """Start the authorizer.""" |
| return babysit('authorizer', '-u', opt.authorizer_url) |
| |
| |
| def stop_authorizer(): |
| subprocess.call(['killpid', pidfile('authorizer')]) |
| |
| |
| def tls_host_lines(tls_hosts): |
| """Make sniproxy config lines allowing hosts or wildcards in tls_hosts.""" |
| config_fragments = [] |
| for host in tls_hosts: |
| # wildcards are easier to work with, but sniproxy needs regexps |
| if '*' in host: |
| host_with_gunk = fnmatch.translate(host) |
| host = '%s$' % host_with_gunk[:-len(fnmatch.translate(''))] |
| host = host.replace('\\', '\\\\') |
| |
| config_fragments.append(' %s *:443' % host) |
| |
| return '\n'.join(config_fragments) |
| |
| |
| def start_sniproxy(): |
| """Start sniproxy, with a custom config if requested on the command line.""" |
| tls_hosts = [] |
| if opt.url: |
| redirect_host = urlparse.urlparse(opt.url).netloc |
| tls_hosts.append(redirect_host) |
| |
| if opt.extra_tls_hosts: |
| tls_hosts += opt.extra_tls_hosts.split() |
| |
| if tls_hosts: |
| tr.helpers.WriteFileAtomic( |
| SNIPROXY_CONFIG_FILE[0], |
| sniproxy_conf % { |
| 'tls_host_lines': tls_host_lines(tls_hosts)}) |
| else: |
| # sniproxy will fall back to a config baked into the image in this case. |
| if os.path.exists(SNIPROXY_CONFIG_FILE[0]): |
| os.unlink(SNIPROXY_CONFIG_FILE[0]) |
| |
| return subprocess.call(['start', 'sniproxy']) |
| |
| |
| def stop_sniproxy(): |
| subprocess.call(['stop', 'sniproxy']) |
| |
| |
| if __name__ == '__main__': |
| o = bup.options.Options(optspec) |
| (opt, unused_flags, extra) = o.parse(sys.argv[1:]) |
| |
| if len(extra) != 1: |
| o.fatal('Got %d commands, want exactly 1.' % len(extra)) |
| |
| exitcode = 0 |
| command = extra[0] |
| |
| if command in ['start', 'restart', 'stop']: |
| stop_authorizer() |
| stop_http_bouncer() |
| stop_iptables() |
| stop_sniproxy() |
| else: |
| o.fatal('Could not understand command: %s' % command) |
| |
| if command in ['start', 'restart']: |
| if opt.allowed_ips: |
| exitcode |= start_allowed_ips() |
| |
| if opt.port and opt.url: |
| exitcode |= start_http_bouncer() |
| |
| if opt.authorizer_url: |
| exitcode |= start_authorizer() |
| |
| # We want sniproxy to be running config for autoprovisioning, |
| # even if the captive portal is otherwise off. |
| exitcode |= start_sniproxy() |
| sys.exit(exitcode) |
| |