Browse code

remove signal handling from aiohttp AppRunner

nord.cli.main takes care of setting up signal handlers on the
event-loop-level, so aiohttp had best not interfere.

Fixes #30

Joseph Weston authored on 19/07/2018 22:44:43
Showing 1 changed files
... ...
@@ -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()
Browse code

add web CLI

Joseph Weston authored on 15/04/2018 18:31:54
Showing 1 changed files
... ...
@@ -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:
Browse code

move main logging configuration to import time

When using nord as a library one does not have to set up explicit
logging. This does not noticeably slow down importing nord.

Joseph Weston authored on 15/04/2018 13:54:17
Showing 1 changed files
... ...
@@ -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
Browse code

remove versioneer and use miniver instead

Joseph Weston authored on 24/02/2018 15:47:38
Showing 1 changed files
... ...
@@ -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',
Browse code

add mord informative help messages to the command-line interface

Joseph Weston authored on 10/09/2017 15:13:36
Showing 1 changed files
... ...
@@ -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
 
Browse code

change command-line interface to choose best server by default

Closes #2

Joseph Weston authored on 10/09/2017 13:34:42
Showing 1 changed files
... ...
@@ -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}")
Browse code

add CLI

Joseph Weston authored on 09/09/2017 16:36:30
Showing 1 changed files
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))