nord.cli.main takes care of setting up signal handlers on the
event-loop-level, so aiohttp had best not interfere.
Fixes #30
... | ... |
@@ -270,7 +270,7 @@ async def web(args): |
270 | 270 |
raise Abort() |
271 | 271 |
|
272 | 272 |
app = nord_web.init_app(client, (username, password)) |
273 |
- runner = aiohttp.web.AppRunner(app, handle_signals=True) |
|
273 |
+ runner = aiohttp.web.AppRunner(app) |
|
274 | 274 |
await runner.setup() |
275 | 275 |
site = aiohttp.web.TCPSite(runner, str(args.host), args.port) |
276 | 276 |
await site.start() |
... | ... |
@@ -22,12 +22,15 @@ import signal |
22 | 22 |
import logging |
23 | 23 |
import argparse |
24 | 24 |
import asyncio |
25 |
+import ipaddress |
|
25 | 26 |
|
26 | 27 |
import structlog |
27 | 28 |
from termcolor import colored |
28 | 29 |
import aiohttp |
30 |
+import aiohttp.web |
|
29 | 31 |
|
30 | 32 |
from . import api, vpn, __version__ |
33 |
+from . import web as nord_web |
|
31 | 34 |
from ._utils import sudo_requires_password, prompt_for_sudo, LockError |
32 | 35 |
|
33 | 36 |
|
... | ... |
@@ -163,6 +166,29 @@ def parse_arguments(): |
163 | 166 |
help='Reject hosts that have a load greater ' |
164 | 167 |
'than this threshold') |
165 | 168 |
|
169 |
+ web_parser = subparsers.add_parser( |
|
170 |
+ 'web', |
|
171 |
+ help="Run nord as a web app", |
|
172 |
+ description="Serve a web app that provides a GUI for selecting the " |
|
173 |
+ "country to connect to.") |
|
174 |
+ |
|
175 |
+ web_parser.add_argument('--debug', action='store_true', |
|
176 |
+ help='Print debugging information') |
|
177 |
+ web_parser.add_argument('-u', '--username', type=str, |
|
178 |
+ required=True, |
|
179 |
+ help='NordVPN account username') |
|
180 |
+ web_parser.add_argument('-P', '--port', type=int, default=8000, |
|
181 |
+ help='Port on which to run the web app') |
|
182 |
+ web_parser.add_argument('-H', '--host', type=ipaddress.ip_address, |
|
183 |
+ default='127.0.0.1', |
|
184 |
+ help='IP address on which to run the web app') |
|
185 |
+ # methods of password entry |
|
186 |
+ passwd = web_parser.add_mutually_exclusive_group(required=True) |
|
187 |
+ passwd.add_argument('-p', '--password', type=str, |
|
188 |
+ help='NordVPN account password') |
|
189 |
+ passwd.add_argument('-f', '--password-file', type=argparse.FileType(), |
|
190 |
+ help='Path to file containing NordVPN password') |
|
191 |
+ |
|
166 | 192 |
args = parser.parse_args() |
167 | 193 |
|
168 | 194 |
if not args.command: |
... | ... |
@@ -218,6 +244,44 @@ async def connect(args): |
218 | 244 |
raise Abort(str(error)) |
219 | 245 |
|
220 | 246 |
|
247 |
+async def web(args): |
|
248 |
+ """Run nord as a web app""" |
|
249 |
+ |
|
250 |
+ username = args.username |
|
251 |
+ password = args.password or args.password_file.readline().strip() |
|
252 |
+ |
|
253 |
+ # Group requests together to reduce overall latency |
|
254 |
+ async with api.Client() as client: |
|
255 |
+ output = await asyncio.gather( |
|
256 |
+ client.valid_credentials(username, password), |
|
257 |
+ sudo_requires_password(), |
|
258 |
+ ) |
|
259 |
+ valid_credentials, require_sudo = output |
|
260 |
+ |
|
261 |
+ if not valid_credentials: |
|
262 |
+ raise Abort('invalid username/password combination') |
|
263 |
+ |
|
264 |
+ if require_sudo: |
|
265 |
+ print('sudo password required for OpenVPN') |
|
266 |
+ try: |
|
267 |
+ await prompt_for_sudo() |
|
268 |
+ except PermissionError: |
|
269 |
+ # 'sudo' will already have notified the user about the failure |
|
270 |
+ raise Abort() |
|
271 |
+ |
|
272 |
+ app = nord_web.init_app(client, (username, password)) |
|
273 |
+ runner = aiohttp.web.AppRunner(app, handle_signals=True) |
|
274 |
+ await runner.setup() |
|
275 |
+ site = aiohttp.web.TCPSite(runner, str(args.host), args.port) |
|
276 |
+ await site.start() |
|
277 |
+ print(colored(f'=== Listening {args.host}:{args.port} ===', |
|
278 |
+ color='white', attrs=['bold'])) |
|
279 |
+ try: |
|
280 |
+ await app['shutdown_signal'].wait() |
|
281 |
+ finally: |
|
282 |
+ await runner.cleanup() |
|
283 |
+ |
|
284 |
+ |
|
221 | 285 |
async def _get_host_and_config(client, args): |
222 | 286 |
# get the host |
223 | 287 |
if args.server: |
When using nord as a library one does not have to set up explicit
logging. This does not noticeably slow down importing nord.
... | ... |
@@ -92,28 +92,9 @@ def render_logs(logger, _, event): |
92 | 92 |
|
93 | 93 |
def setup_logging(args): |
94 | 94 |
"""Set up logging.""" |
95 |
- structlog.configure( |
|
96 |
- processors=[ |
|
97 |
- structlog.stdlib.filter_by_level, |
|
98 |
- structlog.stdlib.add_logger_name, |
|
99 |
- structlog.stdlib.add_log_level, |
|
100 |
- structlog.processors.TimeStamper(fmt="%x:%X", utc=False), |
|
101 |
- structlog.processors.UnicodeDecoder(), |
|
102 |
- render_logs, |
|
103 |
- ], |
|
104 |
- context_class=dict, |
|
105 |
- logger_factory=structlog.stdlib.LoggerFactory(), |
|
106 |
- wrapper_class=structlog.stdlib.BoundLogger, |
|
107 |
- cache_logger_on_first_use=True, |
|
108 |
- ) |
|
95 |
+ cfg = structlog.get_config() |
|
96 |
+ cfg['processors'].append(render_logs) |
|
109 | 97 |
|
110 |
- # set up stdlib logging to be the most permissive, structlog |
|
111 |
- # will handle all filtering and formatting |
|
112 |
- # pylint: disable=protected-access |
|
113 |
- structlog.stdlib.TRACE = 5 |
|
114 |
- structlog.stdlib._NAME_TO_LEVEL['trace'] = 5 |
|
115 |
- structlog.stdlib._LEVEL_TO_NAME[5] = 'trace' |
|
116 |
- logging.addLevelName(5, "TRACE") |
|
117 | 98 |
logging.basicConfig( |
118 | 99 |
stream=sys.stdout, |
119 | 100 |
level=(logging.DEBUG if hasattr(args, 'debug') and args.debug |
... | ... |
@@ -27,7 +27,7 @@ import structlog |
27 | 27 |
from termcolor import colored |
28 | 28 |
import aiohttp |
29 | 29 |
|
30 |
-from . import api, vpn, _version |
|
30 |
+from . import api, vpn, __version__ |
|
31 | 31 |
from ._utils import sudo_requires_password, prompt_for_sudo, LockError |
32 | 32 |
|
33 | 33 |
|
... | ... |
@@ -132,9 +132,8 @@ def parse_arguments(): |
132 | 132 |
description='An unofficial NordVPN client') |
133 | 133 |
subparsers = parser.add_subparsers(dest='command') |
134 | 134 |
|
135 |
- version = _version.get_versions()['version'] |
|
136 | 135 |
parser.add_argument('--version', action='version', |
137 |
- version=f'nord {version}') |
|
136 |
+ version=f'nord {__version__}') |
|
138 | 137 |
|
139 | 138 |
subparsers.add_parser( |
140 | 139 |
'ip_address', |
... | ... |
@@ -127,27 +127,37 @@ def setup_logging(args): |
127 | 127 |
|
128 | 128 |
def parse_arguments(): |
129 | 129 |
"""Return a parser for the Nord command-line interface.""" |
130 |
- parser = argparse.ArgumentParser('nord') |
|
130 |
+ parser = argparse.ArgumentParser( |
|
131 |
+ 'nord', |
|
132 |
+ description='An unofficial NordVPN client') |
|
131 | 133 |
subparsers = parser.add_subparsers(dest='command') |
132 | 134 |
|
133 | 135 |
version = _version.get_versions()['version'] |
134 | 136 |
parser.add_argument('--version', action='version', |
135 | 137 |
version=f'nord {version}') |
136 | 138 |
|
137 |
- subparsers.add_parser('ip_address') |
|
138 |
- |
|
139 |
- connect_parser = subparsers.add_parser('connect') |
|
139 |
+ subparsers.add_parser( |
|
140 |
+ 'ip_address', |
|
141 |
+ help="Get our public IP address, as reported by NordVPN.") |
|
142 |
+ |
|
143 |
+ connect_parser = subparsers.add_parser( |
|
144 |
+ 'connect', |
|
145 |
+ help="connect to a NordVPN server", |
|
146 |
+ description="Connect to a nordVPN server. If the '--server' argument " |
|
147 |
+ "is provided, connect to that specific server, otherwise " |
|
148 |
+ "select all hosts in the provided country, filter them " |
|
149 |
+ "by their load, and select the closest one.") |
|
140 | 150 |
connect_parser.add_argument('--debug', action='store_true', |
141 |
- help='print debugging information') |
|
151 |
+ help='Print debugging information') |
|
142 | 152 |
connect_parser.add_argument('-u', '--username', type=str, |
143 | 153 |
required=True, |
144 |
- help='NordVPN username') |
|
154 |
+ help='NordVPN account username') |
|
145 | 155 |
# methods of password entry |
146 | 156 |
passwd = connect_parser.add_mutually_exclusive_group(required=True) |
147 | 157 |
passwd.add_argument('-p', '--password', type=str, |
148 |
- help='NordVPN password') |
|
158 |
+ help='NordVPN account password') |
|
149 | 159 |
passwd.add_argument('-f', '--password-file', type=argparse.FileType(), |
150 |
- help='path to file containing NordVPN password') |
|
160 |
+ help='Path to file containing NordVPN password') |
|
151 | 161 |
|
152 | 162 |
# pre-filters on the hostlist. Either specify a country or a single host |
153 | 163 |
hosts = connect_parser.add_mutually_exclusive_group(required=True) |
... | ... |
@@ -160,15 +170,18 @@ def parse_arguments(): |
160 | 170 |
return country |
161 | 171 |
|
162 | 172 |
hosts.add_argument('country_code', type=_flag, nargs='?', |
163 |
- help='2-letter country code') |
|
173 |
+ help='2-letter country code, e.g. US, GB') |
|
164 | 174 |
hosts.add_argument('-s', '--server', |
165 |
- help='nordVPN host or fully qualified domain name') |
|
175 |
+ help='NordVPN host or fully qualified domain name, ' |
|
176 |
+ 'e.g us720, us270.nordvpn.com') |
|
166 | 177 |
|
167 | 178 |
# arguments to filter the resulting hostlist |
168 | 179 |
connect_parser.add_argument('--ping-timeout', type=int, default=2, |
169 |
- help='ping wait time') |
|
180 |
+ help='Wait for this long for responses from ' |
|
181 |
+ 'potential hosts') |
|
170 | 182 |
connect_parser.add_argument('--max-load', type=int, default=70, |
171 |
- help='max load') |
|
183 |
+ help='Reject hosts that have a load greater ' |
|
184 |
+ 'than this threshold') |
|
172 | 185 |
|
173 | 186 |
args = parser.parse_args() |
174 | 187 |
|
Closes #2
Joseph Weston authored on 10/09/2017 13:34:42... | ... |
@@ -38,10 +38,8 @@ class Abort(RuntimeError): |
38 | 38 |
def main(): |
39 | 39 |
"""Execute the nord command-line interface""" |
40 | 40 |
# parse command line arguments |
41 |
- parser = command_parser() |
|
42 |
- args = parser.parse_args() |
|
43 |
- if not args.command: |
|
44 |
- parser.error('no command provided') |
|
41 |
+ args = parse_arguments() |
|
42 |
+ |
|
45 | 43 |
command = globals()[args.command] |
46 | 44 |
|
47 | 45 |
setup_logging(args) |
... | ... |
@@ -111,6 +109,11 @@ def setup_logging(args): |
111 | 109 |
|
112 | 110 |
# set up stdlib logging to be the most permissive, structlog |
113 | 111 |
# will handle all filtering and formatting |
112 |
+ # pylint: disable=protected-access |
|
113 |
+ structlog.stdlib.TRACE = 5 |
|
114 |
+ structlog.stdlib._NAME_TO_LEVEL['trace'] = 5 |
|
115 |
+ structlog.stdlib._LEVEL_TO_NAME[5] = 'trace' |
|
116 |
+ logging.addLevelName(5, "TRACE") |
|
114 | 117 |
logging.basicConfig( |
115 | 118 |
stream=sys.stdout, |
116 | 119 |
level=(logging.DEBUG if hasattr(args, 'debug') and args.debug |
... | ... |
@@ -122,7 +125,7 @@ def setup_logging(args): |
122 | 125 |
logging.getLogger('asyncio').propagate = False |
123 | 126 |
|
124 | 127 |
|
125 |
-def command_parser(): |
|
128 |
+def parse_arguments(): |
|
126 | 129 |
"""Return a parser for the Nord command-line interface.""" |
127 | 130 |
parser = argparse.ArgumentParser('nord') |
128 | 131 |
subparsers = parser.add_subparsers(dest='command') |
... | ... |
@@ -146,10 +149,33 @@ def command_parser(): |
146 | 149 |
passwd.add_argument('-f', '--password-file', type=argparse.FileType(), |
147 | 150 |
help='path to file containing NordVPN password') |
148 | 151 |
|
149 |
- connect_parser.add_argument('host', |
|
150 |
- help='nordVPN host or fully qualified ' |
|
151 |
- 'domain name') |
|
152 |
- return parser |
|
152 |
+ # pre-filters on the hostlist. Either specify a country or a single host |
|
153 |
+ hosts = connect_parser.add_mutually_exclusive_group(required=True) |
|
154 |
+ |
|
155 |
+ def _flag(country): |
|
156 |
+ country = str(country).upper() |
|
157 |
+ if len(country) != 2 or not str.isalpha(country): |
|
158 |
+ raise argparse.ArgumentTypeError( |
|
159 |
+ 'must be a 2 letter country code') |
|
160 |
+ return country |
|
161 |
+ |
|
162 |
+ hosts.add_argument('country_code', type=_flag, nargs='?', |
|
163 |
+ help='2-letter country code') |
|
164 |
+ hosts.add_argument('-s', '--server', |
|
165 |
+ help='nordVPN host or fully qualified domain name') |
|
166 |
+ |
|
167 |
+ # arguments to filter the resulting hostlist |
|
168 |
+ connect_parser.add_argument('--ping-timeout', type=int, default=2, |
|
169 |
+ help='ping wait time') |
|
170 |
+ connect_parser.add_argument('--max-load', type=int, default=70, |
|
171 |
+ help='max load') |
|
172 |
+ |
|
173 |
+ args = parser.parse_args() |
|
174 |
+ |
|
175 |
+ if not args.command: |
|
176 |
+ parser.error('no command provided') |
|
177 |
+ |
|
178 |
+ return args |
|
153 | 179 |
|
154 | 180 |
|
155 | 181 |
# Subcommands |
... | ... |
@@ -166,32 +192,22 @@ async def connect(args): |
166 | 192 |
username = args.username |
167 | 193 |
password = args.password or args.password_file.readline().strip() |
168 | 194 |
|
169 |
- # Catch simple errors before we even make a web request |
|
170 |
- host = args.host |
|
171 |
- try: |
|
172 |
- host = api.normalized_hostname(host) |
|
173 |
- except ValueError as error: |
|
174 |
- raise Abort(f'{host} is not a NordVPN server') |
|
175 |
- |
|
176 | 195 |
# Group requests together to reduce overall latency |
177 |
- try: |
|
178 |
- async with api.Client() as client: |
|
179 |
- output = await asyncio.gather( |
|
180 |
- client.valid_credentials(username, password), |
|
181 |
- client.host_config(host), |
|
182 |
- client.dns_servers(), |
|
183 |
- sudo_requires_password(), |
|
184 |
- ) |
|
185 |
- valid_credentials, config, dns_servers, require_sudo = output |
|
186 |
- except aiohttp.ClientResponseError as error: |
|
187 |
- # The only request that can possibly 404 is 'host_config' |
|
188 |
- if error.code != 404: |
|
189 |
- raise |
|
190 |
- raise Abort(f'{host} is not a NordVPN server') |
|
196 |
+ async with api.Client() as client: |
|
197 |
+ output = await asyncio.gather( |
|
198 |
+ _get_host_and_config(client, args), |
|
199 |
+ client.valid_credentials(username, password), |
|
200 |
+ client.dns_servers(), |
|
201 |
+ sudo_requires_password(), |
|
202 |
+ ) |
|
203 |
+ (host, config), valid_credentials, dns_servers, require_sudo = output |
|
191 | 204 |
|
192 | 205 |
if not valid_credentials: |
193 | 206 |
raise Abort('invalid username/password combination') |
194 | 207 |
|
208 |
+ log = structlog.get_logger(__name__) |
|
209 |
+ log.info(f"connecting to {host}") |
|
210 |
+ |
|
195 | 211 |
if require_sudo: |
196 | 212 |
print('sudo password required for OpenVPN') |
197 | 213 |
try: |
... | ... |
@@ -207,3 +223,29 @@ async def connect(args): |
207 | 223 |
'of nord running?') |
208 | 224 |
except vpn.OpenVPNError as error: |
209 | 225 |
raise Abort(str(error)) |
226 |
+ |
|
227 |
+ |
|
228 |
+async def _get_host_and_config(client, args): |
|
229 |
+ # get the host |
|
230 |
+ if args.server: |
|
231 |
+ try: |
|
232 |
+ hosts = [api.normalized_hostname(args.server)] |
|
233 |
+ except ValueError as error: |
|
234 |
+ raise Abort(f'{args.server} is not a NordVPN server') |
|
235 |
+ else: |
|
236 |
+ assert args.country_code |
|
237 |
+ hosts = await client.rank_hosts(args.country_code, |
|
238 |
+ args.max_load, args.ping_timeout) |
|
239 |
+ if not hosts: |
|
240 |
+ raise Abort('no hosts available ' |
|
241 |
+ '(try a higher load or ping threshold?)') |
|
242 |
+ # get the config |
|
243 |
+ for host in hosts: |
|
244 |
+ try: |
|
245 |
+ config = await client.host_config(host) |
|
246 |
+ return host, config |
|
247 |
+ except aiohttp.ClientResponseError as error: |
|
248 |
+ if error.code != 404: |
|
249 |
+ raise # unexpected error |
|
250 |
+ # pylint: disable=undefined-loop-variable |
|
251 |
+ raise Abort(f"config unavailable for {host}") |
1 | 1 |
new file mode 100644 |
... | ... |
@@ -0,0 +1,209 @@ |
1 |
+# -*- coding: utf-8 -*- |
|
2 |
+# |
|
3 |
+# Copyright 2017 Joseph Weston |
|
4 |
+# |
|
5 |
+# This program is free software: you can redistribute it and/or modify |
|
6 |
+# it under the terms of the GNU General Public License as published by |
|
7 |
+# the Free Software Foundation, either version 3 of the License, or |
|
8 |
+# (at your option) any later version. |
|
9 |
+# |
|
10 |
+# This program is distributed in the hope that it will be useful, |
|
11 |
+# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
12 |
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
13 |
+# GNU General Public License for more details. |
|
14 |
+# |
|
15 |
+# You should have received a copy of the GNU General Public License |
|
16 |
+# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
17 |
+"""Command-line interface to the NordVPN client.""" |
|
18 |
+ |
|
19 |
+import sys |
|
20 |
+import traceback |
|
21 |
+import signal |
|
22 |
+import logging |
|
23 |
+import argparse |
|
24 |
+import asyncio |
|
25 |
+ |
|
26 |
+import structlog |
|
27 |
+from termcolor import colored |
|
28 |
+import aiohttp |
|
29 |
+ |
|
30 |
+from . import api, vpn, _version |
|
31 |
+from ._utils import sudo_requires_password, prompt_for_sudo, LockError |
|
32 |
+ |
|
33 |
+ |
|
34 |
+class Abort(RuntimeError): |
|
35 |
+ """Signal the command-line interface to abort.""" |
|
36 |
+ |
|
37 |
+ |
|
38 |
+def main(): |
|
39 |
+ """Execute the nord command-line interface""" |
|
40 |
+ # parse command line arguments |
|
41 |
+ parser = command_parser() |
|
42 |
+ args = parser.parse_args() |
|
43 |
+ if not args.command: |
|
44 |
+ parser.error('no command provided') |
|
45 |
+ command = globals()[args.command] |
|
46 |
+ |
|
47 |
+ setup_logging(args) |
|
48 |
+ |
|
49 |
+ # set up the event loop |
|
50 |
+ loop = asyncio.get_event_loop() |
|
51 |
+ for sig in (signal.SIGHUP, signal.SIGINT, signal.SIGTERM): |
|
52 |
+ loop.add_signal_handler(sig, cancel_all_tasks) |
|
53 |
+ |
|
54 |
+ # dispatch |
|
55 |
+ try: |
|
56 |
+ returncode = loop.run_until_complete(command(args)) |
|
57 |
+ except asyncio.CancelledError: |
|
58 |
+ returncode = 1 |
|
59 |
+ except Abort as error: |
|
60 |
+ print(f"{colored('Error', 'red', attrs=['bold'])}:", error) |
|
61 |
+ returncode = 1 |
|
62 |
+ finally: |
|
63 |
+ remaining_tasks = cancel_all_tasks() |
|
64 |
+ if remaining_tasks: |
|
65 |
+ loop.run_until_complete(asyncio.wait(remaining_tasks)) |
|
66 |
+ loop.close() |
|
67 |
+ |
|
68 |
+ sys.exit(returncode) |
|
69 |
+ |
|
70 |
+ |
|
71 |
+def cancel_all_tasks(): |
|
72 |
+ """Cancel all outstanding tasks on the default event loop.""" |
|
73 |
+ remaining_tasks = asyncio.Task.all_tasks() |
|
74 |
+ for task in remaining_tasks: |
|
75 |
+ task.cancel() |
|
76 |
+ return remaining_tasks |
|
77 |
+ |
|
78 |
+ |
|
79 |
+def render_logs(logger, _, event): |
|
80 |
+ """Render logs into a format suitable for CLI output.""" |
|
81 |
+ if event.get('stream', '') == 'status': |
|
82 |
+ if event['event'] == 'up': |
|
83 |
+ msg = colored('connected', 'green', attrs=['bold']) |
|
84 |
+ elif event['event'] == 'down': |
|
85 |
+ msg = colored('disconnected', 'red', attrs=['bold']) |
|
86 |
+ elif event.get('stream', '') == 'stdout': |
|
87 |
+ msg = f"[stdout @ {event['timestamp']}] {event['event']}" |
|
88 |
+ elif event.get('exc_info'): |
|
89 |
+ msg = traceback.format_exception(*event['exc_info']) |
|
90 |
+ else: |
|
91 |
+ msg = f"{event['event']}" |
|
92 |
+ return f"[{colored(logger.name, attrs=['bold'])}] {msg}" |
|
93 |
+ |
|
94 |
+ |
|
95 |
+def setup_logging(args): |
|
96 |
+ """Set up logging.""" |
|
97 |
+ structlog.configure( |
|
98 |
+ processors=[ |
|
99 |
+ structlog.stdlib.filter_by_level, |
|
100 |
+ structlog.stdlib.add_logger_name, |
|
101 |
+ structlog.stdlib.add_log_level, |
|
102 |
+ structlog.processors.TimeStamper(fmt="%x:%X", utc=False), |
|
103 |
+ structlog.processors.UnicodeDecoder(), |
|
104 |
+ render_logs, |
|
105 |
+ ], |
|
106 |
+ context_class=dict, |
|
107 |
+ logger_factory=structlog.stdlib.LoggerFactory(), |
|
108 |
+ wrapper_class=structlog.stdlib.BoundLogger, |
|
109 |
+ cache_logger_on_first_use=True, |
|
110 |
+ ) |
|
111 |
+ |
|
112 |
+ # set up stdlib logging to be the most permissive, structlog |
|
113 |
+ # will handle all filtering and formatting |
|
114 |
+ logging.basicConfig( |
|
115 |
+ stream=sys.stdout, |
|
116 |
+ level=(logging.DEBUG if hasattr(args, 'debug') and args.debug |
|
117 |
+ else logging.INFO), |
|
118 |
+ format='%(message)s', |
|
119 |
+ ) |
|
120 |
+ |
|
121 |
+ # silence 'asyncio' logging |
|
122 |
+ logging.getLogger('asyncio').propagate = False |
|
123 |
+ |
|
124 |
+ |
|
125 |
+def command_parser(): |
|
126 |
+ """Return a parser for the Nord command-line interface.""" |
|
127 |
+ parser = argparse.ArgumentParser('nord') |
|
128 |
+ subparsers = parser.add_subparsers(dest='command') |
|
129 |
+ |
|
130 |
+ version = _version.get_versions()['version'] |
|
131 |
+ parser.add_argument('--version', action='version', |
|
132 |
+ version=f'nord {version}') |
|
133 |
+ |
|
134 |
+ subparsers.add_parser('ip_address') |
|
135 |
+ |
|
136 |
+ connect_parser = subparsers.add_parser('connect') |
|
137 |
+ connect_parser.add_argument('--debug', action='store_true', |
|
138 |
+ help='print debugging information') |
|
139 |
+ connect_parser.add_argument('-u', '--username', type=str, |
|
140 |
+ required=True, |
|
141 |
+ help='NordVPN username') |
|
142 |
+ # methods of password entry |
|
143 |
+ passwd = connect_parser.add_mutually_exclusive_group(required=True) |
|
144 |
+ passwd.add_argument('-p', '--password', type=str, |
|
145 |
+ help='NordVPN password') |
|
146 |
+ passwd.add_argument('-f', '--password-file', type=argparse.FileType(), |
|
147 |
+ help='path to file containing NordVPN password') |
|
148 |
+ |
|
149 |
+ connect_parser.add_argument('host', |
|
150 |
+ help='nordVPN host or fully qualified ' |
|
151 |
+ 'domain name') |
|
152 |
+ return parser |
|
153 |
+ |
|
154 |
+ |
|
155 |
+# Subcommands |
|
156 |
+ |
|
157 |
+async def ip_address(_): |
|
158 |
+ """Get our public IP address.""" |
|
159 |
+ async with api.Client() as client: |
|
160 |
+ print(await client.current_ip()) |
|
161 |
+ |
|
162 |
+ |
|
163 |
+async def connect(args): |
|
164 |
+ """Connect to a NordVPN server.""" |
|
165 |
+ |
|
166 |
+ username = args.username |
|
167 |
+ password = args.password or args.password_file.readline().strip() |
|
168 |
+ |
|
169 |
+ # Catch simple errors before we even make a web request |
|
170 |
+ host = args.host |
|
171 |
+ try: |
|
172 |
+ host = api.normalized_hostname(host) |
|
173 |
+ except ValueError as error: |
|
174 |
+ raise Abort(f'{host} is not a NordVPN server') |
|
175 |
+ |
|
176 |
+ # Group requests together to reduce overall latency |
|
177 |
+ try: |
|
178 |
+ async with api.Client() as client: |
|
179 |
+ output = await asyncio.gather( |
|
180 |
+ client.valid_credentials(username, password), |
|
181 |
+ client.host_config(host), |
|
182 |
+ client.dns_servers(), |
|
183 |
+ sudo_requires_password(), |
|
184 |
+ ) |
|
185 |
+ valid_credentials, config, dns_servers, require_sudo = output |
|
186 |
+ except aiohttp.ClientResponseError as error: |
|
187 |
+ # The only request that can possibly 404 is 'host_config' |
|
188 |
+ if error.code != 404: |
|
189 |
+ raise |
|
190 |
+ raise Abort(f'{host} is not a NordVPN server') |
|
191 |
+ |
|
192 |
+ if not valid_credentials: |
|
193 |
+ raise Abort('invalid username/password combination') |
|
194 |
+ |
|
195 |
+ if require_sudo: |
|
196 |
+ print('sudo password required for OpenVPN') |
|
197 |
+ try: |
|
198 |
+ await prompt_for_sudo() |
|
199 |
+ except PermissionError: |
|
200 |
+ # 'sudo' will already have notified the user about the failure |
|
201 |
+ raise Abort() |
|
202 |
+ |
|
203 |
+ try: |
|
204 |
+ await vpn.run(config, username, password, dns_servers) |
|
205 |
+ except LockError: |
|
206 |
+ raise Abort('Failed to obtain a lock: is another instance ' |
|
207 |
+ 'of nord running?') |
|
208 |
+ except vpn.OpenVPNError as error: |
|
209 |
+ raise Abort(str(error)) |