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}")
|