nord/cli.py
cb786478
 # -*- 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/>.
 """Command-line interface to the NordVPN client."""
 
 import sys
 import traceback
 import signal
 import logging
 import argparse
 import asyncio
c1adba18
 import ipaddress
cb786478
 
 import structlog
 from termcolor import colored
 import aiohttp
c1adba18
 import aiohttp.web
cb786478
 
00152bee
 from . import api, vpn, __version__
c1adba18
 from . import web as nord_web
cb786478
 from ._utils import sudo_requires_password, prompt_for_sudo, LockError
 
 
 class Abort(RuntimeError):
     """Signal the command-line interface to abort."""
 
 
 def main():
     """Execute the nord command-line interface"""
     # parse command line arguments
af7cf5bc
     args = parse_arguments()
 
cb786478
     command = globals()[args.command]
 
     setup_logging(args)
 
     # set up the event loop
     loop = asyncio.get_event_loop()
     for sig in (signal.SIGHUP, signal.SIGINT, signal.SIGTERM):
         loop.add_signal_handler(sig, cancel_all_tasks)
 
     # dispatch
     try:
         returncode = loop.run_until_complete(command(args))
     except asyncio.CancelledError:
         returncode = 1
     except Abort as error:
         print(f"{colored('Error', 'red', attrs=['bold'])}:", error)
         returncode = 1
     finally:
         remaining_tasks = cancel_all_tasks()
         if remaining_tasks:
             loop.run_until_complete(asyncio.wait(remaining_tasks))
         loop.close()
 
     sys.exit(returncode)
 
 
 def cancel_all_tasks():
     """Cancel all outstanding tasks on the default event loop."""
     remaining_tasks = asyncio.Task.all_tasks()
     for task in remaining_tasks:
         task.cancel()
     return remaining_tasks
 
 
 def render_logs(logger, _, event):
     """Render logs into a format suitable for CLI output."""
     if event.get('stream', '') == 'status':
         if event['event'] == 'up':
             msg = colored('connected', 'green', attrs=['bold'])
         elif event['event'] == 'down':
             msg = colored('disconnected', 'red', attrs=['bold'])
     elif event.get('stream', '') == 'stdout':
         msg = f"[stdout @ {event['timestamp']}] {event['event']}"
     elif event.get('exc_info'):
         msg = traceback.format_exception(*event['exc_info'])
     else:
         msg = f"{event['event']}"
     return f"[{colored(logger.name, attrs=['bold'])}] {msg}"
 
 
 def setup_logging(args):
     """Set up logging."""
46f34ee9
     cfg = structlog.get_config()
     cfg['processors'].append(render_logs)
cb786478
 
     logging.basicConfig(
         stream=sys.stdout,
         level=(logging.DEBUG if hasattr(args, 'debug') and args.debug
                else logging.INFO),
         format='%(message)s',
     )
 
     # silence 'asyncio' logging
     logging.getLogger('asyncio').propagate = False
 
 
af7cf5bc
 def parse_arguments():
cb786478
     """Return a parser for the Nord command-line interface."""
7a3e2805
     parser = argparse.ArgumentParser(
         'nord',
         description='An unofficial NordVPN client')
cb786478
     subparsers = parser.add_subparsers(dest='command')
 
     parser.add_argument('--version', action='version',
00152bee
                         version=f'nord {__version__}')
cb786478
 
7a3e2805
     subparsers.add_parser(
         'ip_address',
         help="Get our public IP address, as reported by NordVPN.")
 
     connect_parser = subparsers.add_parser(
         'connect',
         help="connect to a NordVPN server",
         description="Connect to a nordVPN server. If the '--server' argument "
                     "is provided, connect to that specific server, otherwise "
                     "select all hosts in the provided country, filter them "
                     "by their load, and select the closest one.")
cb786478
     connect_parser.add_argument('--debug', action='store_true',
7a3e2805
                                 help='Print debugging information')
cb786478
     connect_parser.add_argument('-u', '--username', type=str,
                                 required=True,
7a3e2805
                                 help='NordVPN account username')
cb786478
     # methods of password entry
     passwd = connect_parser.add_mutually_exclusive_group(required=True)
     passwd.add_argument('-p', '--password', type=str,
7a3e2805
                         help='NordVPN account password')
cb786478
     passwd.add_argument('-f', '--password-file', type=argparse.FileType(),
7a3e2805
                         help='Path to file containing NordVPN password')
cb786478
 
af7cf5bc
     # pre-filters on the hostlist. Either specify a country or a single host
     hosts = connect_parser.add_mutually_exclusive_group(required=True)
 
     def _flag(country):
         country = str(country).upper()
         if len(country) != 2 or not str.isalpha(country):
             raise argparse.ArgumentTypeError(
                 'must be a 2 letter country code')
         return country
 
     hosts.add_argument('country_code', type=_flag, nargs='?',
7a3e2805
                        help='2-letter country code, e.g. US, GB')
af7cf5bc
     hosts.add_argument('-s', '--server',
7a3e2805
                        help='NordVPN host or fully qualified domain name, '
                             'e.g us720, us270.nordvpn.com')
af7cf5bc
 
     # arguments to filter the resulting hostlist
     connect_parser.add_argument('--ping-timeout', type=int, default=2,
7a3e2805
                                 help='Wait for this long for responses from '
                                      'potential hosts')
af7cf5bc
     connect_parser.add_argument('--max-load', type=int, default=70,
7a3e2805
                                 help='Reject hosts that have a load greater '
                                      'than this threshold')
af7cf5bc
 
c1adba18
     web_parser = subparsers.add_parser(
         'web',
         help="Run nord as a web app",
         description="Serve a web app that provides a GUI for selecting the "
                     "country to connect to.")
 
     web_parser.add_argument('--debug', action='store_true',
                             help='Print debugging information')
     web_parser.add_argument('-u', '--username', type=str,
                             required=True,
                             help='NordVPN account username')
     web_parser.add_argument('-P', '--port', type=int, default=8000,
                             help='Port on which to run the web app')
     web_parser.add_argument('-H', '--host', type=ipaddress.ip_address,
                             default='127.0.0.1',
                             help='IP address on which to run the web app')
     # methods of password entry
     passwd = web_parser.add_mutually_exclusive_group(required=True)
     passwd.add_argument('-p', '--password', type=str,
                         help='NordVPN account password')
     passwd.add_argument('-f', '--password-file', type=argparse.FileType(),
                         help='Path to file containing NordVPN password')
 
af7cf5bc
     args = parser.parse_args()
 
     if not args.command:
         parser.error('no command provided')
 
     return args
cb786478
 
 
 # Subcommands
 
 async def ip_address(_):
     """Get our public IP address."""
     async with api.Client() as client:
         print(await client.current_ip())
 
 
 async def connect(args):
     """Connect to a NordVPN server."""
 
     username = args.username
     password = args.password or args.password_file.readline().strip()
 
     # Group requests together to reduce overall latency
af7cf5bc
     async with api.Client() as client:
         output = await asyncio.gather(
             _get_host_and_config(client, args),
             client.valid_credentials(username, password),
             client.dns_servers(),
             sudo_requires_password(),
         )
     (host, config), valid_credentials, dns_servers, require_sudo = output
cb786478
 
     if not valid_credentials:
         raise Abort('invalid username/password combination')
 
af7cf5bc
     log = structlog.get_logger(__name__)
     log.info(f"connecting to {host}")
 
cb786478
     if require_sudo:
         print('sudo password required for OpenVPN')
         try:
             await prompt_for_sudo()
         except PermissionError:
             # 'sudo' will already have notified the user about the failure
             raise Abort()
 
     try:
         await vpn.run(config, username, password, dns_servers)
     except LockError:
         raise Abort('Failed to obtain a lock: is another instance '
                     'of nord running?')
     except vpn.OpenVPNError as error:
         raise Abort(str(error))
af7cf5bc
 
 
c1adba18
 async def web(args):
     """Run nord as a web app"""
 
     username = args.username
     password = args.password or args.password_file.readline().strip()
 
     # Group requests together to reduce overall latency
     async with api.Client() as client:
         output = await asyncio.gather(
             client.valid_credentials(username, password),
             sudo_requires_password(),
         )
         valid_credentials, require_sudo = output
 
         if not valid_credentials:
             raise Abort('invalid username/password combination')
 
         if require_sudo:
             print('sudo password required for OpenVPN')
             try:
                 await prompt_for_sudo()
             except PermissionError:
                 # 'sudo' will already have notified the user about the failure
                 raise Abort()
 
         app = nord_web.init_app(client, (username, password))
3f9d79ee
         runner = aiohttp.web.AppRunner(app)
c1adba18
         await runner.setup()
         site = aiohttp.web.TCPSite(runner, str(args.host), args.port)
         await site.start()
         print(colored(f'=== Listening {args.host}:{args.port} ===',
                       color='white', attrs=['bold']))
         try:
             await app['shutdown_signal'].wait()
         finally:
             await runner.cleanup()
 
 
af7cf5bc
 async def _get_host_and_config(client, args):
     # get the host
     if args.server:
         try:
             hosts = [api.normalized_hostname(args.server)]
         except ValueError as error:
             raise Abort(f'{args.server} is not a NordVPN server')
     else:
         assert args.country_code
         hosts = await client.rank_hosts(args.country_code,
                                         args.max_load, args.ping_timeout)
         if not hosts:
             raise Abort('no hosts available '
                         '(try a higher load or ping threshold?)')
     # get the config
     for host in hosts:
         try:
             config = await client.host_config(host)
             return host, config
         except aiohttp.ClientResponseError as error:
             if error.code != 404:
                 raise  # unexpected error
     # pylint: disable=undefined-loop-variable
     raise Abort(f"config unavailable for {host}")