Source code for nord.vpn

# -*- coding: utf-8 -*-
#
# Copyright 2017 Joseph Weston
#
# 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/>.
"""Tools for starting and supervising OpenVPN clients."""

import sys
import asyncio

from structlog import get_logger

from ._utils import (write_to_tmp, lock_subprocess, kill_root_process,
                     require_sudo, maintain_sudo, multi_context,
                     replace_content_as_root)


OPENVPN_EXECUTABLE = '/usr/sbin/openvpn'
LOCKFILE = '/run/lock/nordvpn.lockfile'
_OPENVPN_UP = b'Initialization Sequence Completed'


[docs]class OpenVPNError(RuntimeError): """Errors from the OpenVPN subprocess"""
[docs]@require_sudo async def start(config, username, password): """Start an OpenVPN client with the given configuration. Parameters ---------- config : str The contents of the OpenVPN config file. username, password : str Credentials for the OpenVPN connection. Returns ------- proc : asyncio.subprocess.Process Raises ------ PermissionError if we cannot use 'sudo' without a password. OpenVPNError if the OpenVPN process does not start correctly. LockError if a lock could not be obtained for the lockfile. Notes ----- Obtains a lock on a global lockfile before launching an OpenVPN client in a subprocess. The lock is released when the process dies. """ logger = get_logger(__name__) config_file = write_to_tmp(config) credentials_file = write_to_tmp(f'{username}\n{password}') cmd = ['sudo', '-n', OPENVPN_EXECUTABLE, '--suppress-timestamps', '--config', config_file.name, '--auth-user-pass', credentials_file.name, ] proc = None # detach the child from the current process group so as to # avoid signals destined for *this* process. # *we* handle signals properly with 'kill_root_process' def _protect_child(): import os os.setpgrp() try: proc = await lock_subprocess(*cmd, stdout=asyncio.subprocess.PIPE, preexec_fn=_protect_child, lockfile=LOCKFILE) logger = logger.bind(pid=proc.pid) # Wait until OpenVPN connects, indicated by a particular line in stdout stdout = b'' while _OPENVPN_UP not in stdout: stdout = await proc.stdout.readline() if not stdout: # 'readline' returned empty; stdout is closed. # Even if OpenVPN is not dead, we have no way of knowing # whether the connection is up or not, so we kill it anyway. raise OpenVPNError('OpenVPN failed to start') logger.debug(stdout.decode().rstrip(), stream='stdout') except OpenVPNError: logger.debug('failed to start') raise except asyncio.CancelledError: logger.debug('received cancellation while starting') if proc: await asyncio.shield(kill_root_process(proc)) raise except Exception: logger.error('unexpected exception', exc_info=sys.exc_info()) if proc: await asyncio.shield(kill_root_process(proc)) raise finally: config_file.close() credentials_file.close() logger.info('up', stream='status') return proc
[docs]async def supervise(proc): """Supervise a process. This coroutine supervises a process and writes its stdout to a logger until it dies, or until the coroutine is cancelled, when the process will be killed. Parameters ---------- proc : asyncio.subprocess.Process Returns ------- returncode : int 'proc.returncode'. """ logger = get_logger(__name__).bind(pid=proc.pid) try: stdout = await proc.stdout.readline() while stdout: logger.debug(stdout.decode().rstrip(), stream='stdout') stdout = await proc.stdout.readline() # stdout is closed -- wait for the process to terminate await proc.wait() except asyncio.CancelledError: logger.debug('received cancellation') else: stdout, _ = await proc.communicate() stdout = (l.rstrip() for l in stdout.decode().split('\n')) for line in (l for l in stdout if l): logger.debug(line, stream='stdout') logger.warn('unexpected exit', return_code=proc.returncode) finally: logger.debug('cleaning up OpenVPN') await asyncio.shield(kill_root_process(proc)) logger.info('down', stream='status') return proc.returncode
[docs]@require_sudo async def supervise_with_context(proc, dns_servers=()): """Supervise an OpenVPN client until it dies and return the exit code. Optionally provide DNS servers that will replace the contents of '/etc/resolv.conf' for the duration of the client. Parameters ---------- proc : asyncio.Task The OpenVPN process to supervise dns_servers : tuple of str, optional IP addresses of DNS servers with which to populate '/etc/resolv.conv' when the VPN is up. """ # While OpenVPN is running we need to maintain the cached sudo # credentials, otherwise they will time out after 15 minutes or so # and we will not be able to kill the OpenVPN process. context = [maintain_sudo()] if dns_servers: dns_servers = ['nameserver ' + server for server in dns_servers] context.append(replace_content_as_root('/etc/resolv.conf', '\n'.join(dns_servers))) async with multi_context(*context): return await supervise(proc)
[docs]@require_sudo async def run(config, username, password, dns_servers=()): """Run an OpenVPN client until it dies and return the exit code. Optionally provide DNS servers that will replace the contents of '/etc/resolv.conf' for the duration of the client. Parameters ---------- config : str The contents of the OpenVPN config file. username, password : str Credentials for the OpenVPN connection. dns_servers : tuple of str, optional IP addresses of DNS servers with which to populate '/etc/resolv.conv' when the VPN is up. """ proc = await start(config, username, password) return await supervise_with_context(proc, dns_servers)