diff options
| -rw-r--r-- | src/leap/bitmask/cli/vpn.py | 2 | ||||
| -rwxr-xr-x | src/leap/bitmask/vpn/fw/osx/bitmask-helper | 430 | ||||
| -rw-r--r-- | src/leap/bitmask/vpn/fw/osx/bitmask.pf.conf | 17 | 
3 files changed, 448 insertions, 1 deletions
diff --git a/src/leap/bitmask/cli/vpn.py b/src/leap/bitmask/cli/vpn.py index 6683f6ff..d80b7f39 100644 --- a/src/leap/bitmask/cli/vpn.py +++ b/src/leap/bitmask/cli/vpn.py @@ -1,5 +1,5 @@  # -*- coding: utf-8 -*- -# vpn  +# vpn  # Copyright (C) 2016-2017 LEAP  #  # This program is free software: you can redistribute it and/or modify diff --git a/src/leap/bitmask/vpn/fw/osx/bitmask-helper b/src/leap/bitmask/vpn/fw/osx/bitmask-helper new file mode 100755 index 00000000..a1a3e86a --- /dev/null +++ b/src/leap/bitmask/vpn/fw/osx/bitmask-helper @@ -0,0 +1,430 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Author: Kali Kaneko +# Copyright (C) 2015-2016 LEAP Encryption Access Project +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +""" +This is a privileged helper script for safely running certain commands as root +under OSX. + +It should be run by launchd, and it exposes a Unix Domain Socket to where +the following commmands can be written by the Bitmask application: + +  firewall_start [restart] GATEWAY1 GATEWAY2 ... +  firewall_stop +  openvpn_start CONFIG1 CONFIG1 ... +  openvpn_stop +  fw_email_start uid +  fw_email_stop + +To load it manually: + +  sudo launchctl load /Library/LaunchDaemons/se.leap.bitmask-helper + +To see the loaded rules: + +  sudo pfctl -s rules -a bitmask + +""" +import os +import re +import socket +import signal +import subprocess +import syslog +import threading + +from commands import getoutput as exec_cmd +from functools import partial + +import daemon + +VERSION = "1" +SCRIPT = "bitmask-helper" +NAMESERVER = "10.42.0.1" +BITMASK_ANCHOR = "com.apple/250.BitmaskFirewall" +BITMASK_ANCHOR_EMAIL = "bitmask_email" + +OPENVPN_USER = 'nobody' +OPENVPN_GROUP = 'nogroup' +LEAPOPENVPN = 'LEAPOPENVPN' +APP_PATH = '/Applications/Bitmask.app/' +RESOURCES_PATH = APP_PATH + 'Contents/Resources/' +OPENVPN_LEAP_BIN = RESOURCES_PATH + 'openvpn.leap' + +FIXED_FLAGS = [ +    "--setenv", "LEAPOPENVPN", "1", +    "--nobind", +    "--client", +    "--dev", "tun", +    "--tls-client", +    "--remote-cert-tls", "server", +    "--management-signal", +    "--script-security", "1", +    "--user", "nobody", +    "--remap-usr1", "SIGTERM", +    "--group", OPENVPN_GROUP, +] + +ALLOWED_FLAGS = { +    "--remote": ["IP", "NUMBER", "PROTO"], +    "--tls-cipher": ["CIPHER"], +    "--cipher": ["CIPHER"], +    "--auth": ["CIPHER"], +    "--management": ["DIR", "UNIXSOCKET"], +    "--management-client-user": ["USER"], +    "--cert": ["FILE"], +    "--key": ["FILE"], +    "--ca": ["FILE"], +    "--fragment": ["NUMBER"] +} + +PARAM_FORMATS = { +    "NUMBER": lambda s: re.match("^\d+$", s), +    "PROTO": lambda s: re.match("^(tcp|udp)$", s), +    "IP": lambda s: is_valid_address(s), +    "CIPHER": lambda s: re.match("^[A-Z0-9-]+$", s), +    "USER": lambda s: re.match( +        "^[a-zA-Z0-9_\.\@][a-zA-Z0-9_\-\.\@]*\$?$", s),  # IEEE Std 1003.1-2001 +    "FILE": lambda s: os.path.isfile(s), +    "DIR": lambda s: os.path.isdir(os.path.split(s)[0]), +    "UNIXSOCKET": lambda s: s == "unix", +    "UID": lambda s: re.match("^[a-zA-Z0-9]+$", s) +} + +# +# paths (must use absolute paths, since this script is run as root) +# + +PFCTL = '/sbin/pfctl' +ROUTE = '/sbin/route' +AWK = '/usr/bin/awk' +GREP = '/usr/bin/grep' +CAT = '/bin/cat' + +UID = os.getuid() +SERVER_ADDRESS = '/tmp/bitmask-helper.socket' + + +# +# COMMAND DISPATCH +# + +def serve_forever(): +    try: +        os.unlink(SERVER_ADDRESS) +    except OSError: +        if os.path.exists(SERVER_ADDRESS): +            raise + +    syslog.syslog(syslog.LOG_WARNING, "serving forever") +    # XXX should check permissions on the socket file +    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +    sock.bind(SERVER_ADDRESS) +    sock.listen(1) +    syslog.syslog(syslog.LOG_WARNING, "Binded to %s" % SERVER_ADDRESS) + +    while True: +        connection, client_address = sock.accept() +        thread = threading.Thread(target=handle_command, args=[connection]) +        thread.daemon = True +        thread.start() +     +def recv_until_marker(sock): +    end = '/CMD' +    total_data=[] +    data='' +    while True: +        data=sock.recv(8192) +        if end in data: +            total_data.append(data[:data.find(end)]) +            break +        total_data.append(data) +        if len(total_data)>1: +            #check if end_of_data was split +            last_pair=total_data[-2]+total_data[-1] +            if end in last_pair: +                total_data[-2] = last_pair[:last_pair.find(end)] +                total_data.pop() +                break +    return ''.join(total_data) + + +def handle_command(sock): +    syslog.syslog(syslog.LOG_WARNING, "handle") + +    received = recv_until_marker(sock) +    syslog.syslog(syslog.LOG_WARNING, "GOT -----> %s" % received) +    line = received.replace('\n', '').split(' ') + +    command, args = line[0], line[1:] +    syslog.syslog(syslog.LOG_WARNING, 'command %s' % (command)) + +    cmd_dict = { +        'firewall_start': (firewall_start, args), +        'firewall_stop': (firewall_stop, []), +        'firewall_isup': (firewall_isup, []), +        'openvpn_start': (openvpn_start, args), +        'openvpn_stop': (openvpn_stop, []), +        'openvpn_force_stop': (openvpn_stop, ['KILL']), +        'openvpn_set_watcher': (openvpn_set_watcher, args) +    } + +    cmd_call = cmd_dict.get(command, None) +    syslog.syslog(syslog.LOG_WARNING, 'call: %s' % (str(cmd_call))) +    try: +        if cmd_call: +            syslog.syslog( +                syslog.LOG_WARNING, 'GOT "%s"' % (command)) +            cmd, args = cmd_call +            if args: +                cmd = partial(cmd, *args) + +            # TODO Use a MUTEX in here +            result = cmd() +            syslog.syslog(syslog.LOG_WARNING, "Executed") +            syslog.syslog(syslog.LOG_WARNING, "Result: %s" % (str(result))) +            if result == 'YES': +                sock.sendall("%s: YES\n" % command) +            elif result == 'NO': +                sock.sendall("%s: NO\n" % command) +            else: +                sock.sendall("%s: OK\n" % command) + +        else: +            syslog.syslog(syslog.LOG_WARNING, 'invalid command: %s' % (command,)) +            sock.sendall("%s: ERROR\n" % command) +    except Exception as exc: +        syslog.syslog(syslog.LOG_WARNING, "error executing function %r" % (exc)) +    finally: +        sock.close() + + + +# +# OPENVPN +# + + +openvpn_proc = None +openvpn_watcher_pid = None + + +def openvpn_start(*args): +    """ +    Sanitize input and run openvpn as a subprocess of this long-running daemon. +    Keeps a reference to the subprocess Popen class instance. + +    :param args: arguments to be passed to openvpn +    :type args: list +    """ +    syslog.syslog(syslog.LOG_WARNING, "OPENVPN START") +    opts = list(args[1:]) + +    opts += ['--dhcp-option', 'DNS', '10.42.0.1', +             '--up', RESOURCES_PATH + 'client.up.sh', +             '--down', RESOURCES_PATH + 'client.down.sh'] +    binary = [RESOURCES_PATH + 'openvpn.leap'] + +    syslog.syslog(syslog.LOG_WARNING, ' '.join(binary + opts)) + +    # TODO sanitize options +    global openvpn_proc +    openvpn_proc = subprocess.Popen(binary + opts, shell=False) +    syslog.syslog(syslog.LOG_WARNING, "OpenVPN PID: %s" % str(openvpn_proc.pid)) + + +def openvpn_stop(sig='TERM'): +    """ +    Stop the openvpn that has been launched by this privileged helper. + +    :param args: arguments to openvpn +    :type args: list +    """ +    global openvpn_proc +     +    if openvpn_proc: +        syslog.syslog(syslog.LOG_WARNING, "OVPN PROC: %s" % str(openvpn_proc.pid)) + +        if sig == 'KILL': +            stop_signal = signal.SIGKILL +            openvpn_proc.kill() +        elif sig == 'TERM': +            stop_signal = signal.SIGTERM +            openvpn_proc.terminate() +     +        returncode = openvpn_proc.wait() +        syslog.syslog(syslog.LOG_WARNING, "openvpn return code: %s" % str(returncode)) +        syslog.syslog(syslog.LOG_WARNING, "openvpn_watcher_pid: %s" % str(openvpn_watcher_pid)) +        if openvpn_watcher_pid: +            os.kill(openvpn_watcher_pid, stop_signal) + + +def openvpn_set_watcher(pid, *args): +    global openvpn_watcher_pid +    openvpn_watcher_pid = int(pid) +    syslog.syslog(syslog.LOG_WARNING, "Watcher PID: %s" % pid) + + +# +# FIREWALL +# + + +def firewall_start(*gateways): +    """ +    Bring up the firewall. + +    :param gws: list of gateways, to be sanitized. +    :type gws: list +    """ + +    gateways = get_gateways(gateways) + +    if not gateways: +        return False + +    _enable_pf() +    _reset_bitmask_gateways_table(gateways) + +    default_device = _get_default_device() +    _load_bitmask_anchor(default_device) + + +def firewall_stop(): +    """ +    Flush everything from anchor bitmask +    """ +    cmd = '{pfctl} -a {anchor} -F all'.format( +        pfctl=PFCTL, anchor=BITMASK_ANCHOR) +    return exec_cmd(cmd) + + +def firewall_isup(): +    """ +    Return YES if anchor bitmask is loaded with rules +    """ +    syslog.syslog(syslog.LOG_WARNING, 'PID---->%s' % os.getpid()) +    cmd = '{pfctl} -s rules -a {anchor} | wc -l'.format( +        pfctl=PFCTL, anchor=BITMASK_ANCHOR) +    output = exec_cmd(cmd) +    rules = output[-1] +    if int(rules) > 0: +        return 'YES' +    else: +        return 'NO' + + +def _enable_pf(): +    exec_cmd('{pfctl} -e'.format(pfctl=PFCTL)) + + +def _reset_bitmask_gateways_table(gateways): +    cmd = '{pfctl} -a {anchor} -t bitmask_gateways -T delete'.format( +        pfctl=PFCTL, anchor=BITMASK_ANCHOR) +    output = exec_cmd(cmd) + +    for gateway in gateways: +        cmd = '{pfctl} -a {anchor} -t bitmask_gateways -T add {gw}'.format( +            pfctl=PFCTL, anchor=BITMASK_ANCHOR, gw=gateway) +        output = exec_cmd(cmd) +        syslog.syslog(syslog.LOG_WARNING, "adding gw %s" % gateway) + +    #cmd = '{pfctl} -a {anchor} -t bitmask_nameservers -T delete'.format( +    #    pfctl=PFCTL, anchor=BITMASK_ANCHOR) +    #output = exec_cmd(cmd) + +    cmd = '{pfctl} -a {anchor} -t bitmask_gateways -T add {ns}'.format( +        pfctl=PFCTL, anchor=BITMASK_ANCHOR, ns=NAMESERVER) +    output = exec_cmd(cmd) +    syslog.syslog(syslog.LOG_WARNING, "adding ns %s" % NAMESERVER) + +def _load_bitmask_anchor(default_device): +    cmd = ('{pfctl} -D default_device={defaultdevice} ' +          '-a {anchor} -f {rulefile}').format( +        pfctl=PFCTL, defaultdevice=default_device, +        anchor=BITMASK_ANCHOR, +        rulefile=RESOURCES_PATH + 'bitmask-helper/bitmask.pf.conf') +    syslog.syslog(syslog.LOG_WARNING, "LOADING CMD: %s" % cmd) +    return exec_cmd(cmd) + + +def _get_default_device(): +    """ +    Retrieve the current default network device. + +    :rtype: str +    """ +    cmd_def_device = ( +        '{route} -n get -net default | ' +        '{grep} interface | {awk} "{{print $2}}"').format( +            route=ROUTE, grep=GREP, awk=AWK) +    iface = exec_cmd(cmd_def_device) +    iface = iface.replace("interface: ", "").strip() +    syslog.syslog(syslog.LOG_WARNING, "default device %s" % iface) +    return iface + + + +# +# UTILITY +# + + +def is_valid_address(value): +    """ +    Validate that the passed ip is a valid IP address. + +    :param value: the value to be validated +    :type value: str +    :rtype: bool +    """ +    try: +        socket.inet_aton(value) +        return True +    except Exception: +        syslog.syslog(syslog.LOG_WARNING, 'MALFORMED IP: %s!' % (value)) +        return False + + +# +# FIREWALL +# + + +def get_gateways(gateways): +    """ +    Filter a passed sequence of gateways, returning only the valid ones. + +    :param gateways: a sequence of gateways to filter. +    :type gateways: iterable +    :rtype: iterable +    """ +    syslog.syslog(syslog.LOG_WARNING, 'Filtering %s' % str(gateways)) +    result = filter(is_valid_address, gateways) +    if not result: +        syslog.syslog(syslog.LOG_ERR, 'No valid gateways specified') +        return False +    else: +        return result + + + +if __name__ == "__main__": +    with daemon.DaemonContext(): +        syslog.syslog(syslog.LOG_WARNING, "Serving...")  +        serve_forever() diff --git a/src/leap/bitmask/vpn/fw/osx/bitmask.pf.conf b/src/leap/bitmask/vpn/fw/osx/bitmask.pf.conf new file mode 100644 index 00000000..eb0e858f --- /dev/null +++ b/src/leap/bitmask/vpn/fw/osx/bitmask.pf.conf @@ -0,0 +1,17 @@ +default_device = "en99" + +set block-policy drop +set skip on lo0 + +# block all traffic on default device +block out on $default_device all + +# allow traffic to gateways +pass out on $default_device to <bitmask_gateways> + +# allow traffic to local networks over the default device +pass out on $default_device to $default_device:network + +# block all DNS, except to the gateways +block out proto udp to any port 53 +pass out proto udp to <bitmask_gateways> port 53  | 
