eduvpn-client 4.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
eduvpn/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "4.3.0"
eduvpn/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from eduvpn.cli import eduvpn
2
+
3
+ if __name__ == "__main__":
4
+ eduvpn()
eduvpn/app.py ADDED
@@ -0,0 +1,628 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import signal
5
+ import time
6
+ import webbrowser
7
+ from typing import Any, Callable, Iterator, Optional, TextIO
8
+
9
+ from eduvpn_common.main import EduVPN, ServerType, WrappedError
10
+ from eduvpn_common.state import State, StateType
11
+ from eduvpn_common.types import ProxyReady, ProxySetup, ReadRxBytes
12
+
13
+ from eduvpn import nm
14
+ from eduvpn.config import Configuration
15
+ from eduvpn.connection import Config, Connection, Protocol, parse_config, parse_expiry, parse_tokens
16
+ from eduvpn.keyring import DBusKeyring, InsecureFileKeyring, TokenKeyring
17
+ from eduvpn.server import ServerDatabase, parse_profiles, parse_required_transition
18
+ from eduvpn.utils import (
19
+ handle_exception,
20
+ model_transition,
21
+ run_in_background_thread,
22
+ run_in_glib_thread,
23
+ set_failovered,
24
+ set_online_detecting,
25
+ )
26
+ from eduvpn.variants import ApplicationVariant
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class ApplicationModelTransitions:
32
+ def __init__(self, common: EduVPN, variant: ApplicationVariant) -> None:
33
+ self.common = common
34
+ self.common.register_class_callbacks(self)
35
+ self.server_db = ServerDatabase(common, variant.use_predefined_servers)
36
+
37
+ @model_transition(State.MAIN, StateType.ENTER)
38
+ def get_previous_servers(self, old_state: State, data):
39
+ logger.debug(f"Transition: MAIN, old state: {old_state}")
40
+ return self.server_db.configured
41
+
42
+ @model_transition(State.DEREGISTERED, StateType.ENTER)
43
+ def deregistered(self, old_state: State, data: str):
44
+ logger.debug(f"Transition: DEREGISTERED, old state: {old_state}")
45
+ return data
46
+
47
+ @model_transition(State.ADDING_SERVER, StateType.ENTER)
48
+ def adding_server(self, old_state: State, data: str):
49
+ logger.debug(f"Transition: ADDING_SERVER, old state: {old_state}")
50
+ return data
51
+
52
+ @model_transition(State.GETTING_CONFIG, StateType.ENTER)
53
+ def getting_config(self, old_state: State, data: str):
54
+ logger.debug(f"Transition: GETTING_CONFIG, old state: {old_state}")
55
+ return data
56
+
57
+ @model_transition(State.DISCONNECTED, StateType.ENTER)
58
+ def disconnected_server(self, old_state: State, data: str):
59
+ logger.debug(f"Transition: DISCONNECTED, old state: {old_state}")
60
+ return self.server_db.current
61
+
62
+ @model_transition(State.DISCONNECTING, StateType.ENTER)
63
+ def disconnecting(self, old_state: State, data):
64
+ logger.debug(f"Transition: DISCONNECTING, old state: {old_state}")
65
+ return self.server_db.current
66
+
67
+ @model_transition(State.ASK_PROFILE, StateType.ENTER)
68
+ def ask_profile(self, old_state: State, data: str):
69
+ logger.debug(f"Transition: ASK_PROFILE, old state: {old_state}")
70
+ cookie, profiles = parse_required_transition(data, get=parse_profiles)
71
+
72
+ def set_profile(prof):
73
+ self.common.cookie_reply(cookie, prof)
74
+
75
+ return (set_profile, profiles)
76
+
77
+ @model_transition(State.ASK_LOCATION, StateType.ENTER)
78
+ def ask_location(self, old_state: State, data):
79
+ cookie, locations = parse_required_transition(data)
80
+
81
+ def set_location(loc):
82
+ self.common.cookie_reply(cookie, loc)
83
+
84
+ return (set_location, locations)
85
+
86
+ @model_transition(State.OAUTH_STARTED, StateType.ENTER)
87
+ def start_oauth(self, old_state: State, url: str):
88
+ logger.debug(f"Transition: OAUTH_STARTED, old state: {old_state}")
89
+ self.open_browser(url)
90
+ return url
91
+
92
+ @model_transition(State.GOT_CONFIG, StateType.ENTER)
93
+ def parse_config(self, old_state: State, data):
94
+ logger.debug(f"Transition: GOT_CONFIG, old state: {old_state}")
95
+ return self.server_db.current
96
+
97
+ @run_in_background_thread("open-browser")
98
+ def open_browser(self, url):
99
+ logger.debug(f"Opening web browser with url: {url}")
100
+ webbrowser.open(url)
101
+ # Explicitly wait to not have zombie processes
102
+ # See https://bugs.python.org/issue5993
103
+ logger.debug("Running os.wait for browser")
104
+ try:
105
+ os.wait()
106
+ except ChildProcessError:
107
+ pass
108
+ logger.debug("Done waiting for browser")
109
+
110
+ @model_transition(State.CONNECTED, StateType.ENTER)
111
+ def parse_connected(self, old_state: State, data):
112
+ logger.debug(f"Transition: CONNECTED, old state: {old_state}")
113
+ server = self.server_db.current
114
+ expire_times = parse_expiry(self.common.get_expiry_times())
115
+ return (server, expire_times)
116
+
117
+ @model_transition(State.CONNECTING, StateType.ENTER)
118
+ def parse_connecting(self, old_state: State, data):
119
+ logger.debug(f"Transition: CONNECTING, old state: {old_state}")
120
+ return self.server_db.current
121
+
122
+
123
+ class ApplicationModel:
124
+ def __init__(
125
+ self,
126
+ common: EduVPN,
127
+ config,
128
+ variant: ApplicationVariant,
129
+ nm_manager,
130
+ ) -> None:
131
+ self.common = common
132
+ self.config = config
133
+ self._keyring: TokenKeyring | None = None
134
+ self.transitions = ApplicationModelTransitions(common, variant)
135
+ self.variant = variant
136
+ self.nm_manager = nm_manager
137
+ self._was_tcp = False
138
+ self._should_failover = False
139
+ self._peer_ips_proxy = None
140
+
141
+ @property
142
+ def keyring(self):
143
+ if self._keyring is not None:
144
+ return self._keyring
145
+ keyring = DBusKeyring(self.variant)
146
+ if not keyring.available:
147
+ logger.warning("Secure keyring not available, reverting to insecure file keyring!")
148
+ keyring = InsecureFileKeyring(self.variant)
149
+ self._keyring = keyring
150
+ return self._keyring
151
+
152
+ def register(self, debug: bool):
153
+ self.common.register(debug=debug)
154
+ self.common.set_token_handler(self.load_tokens, self.save_tokens)
155
+
156
+ def cancel(self):
157
+ # Cancel any eduvpn-common operation
158
+ self.common.cancel()
159
+
160
+ # Cancel any NetworkManager operation
161
+ self.nm_manager.cancel()
162
+
163
+ @property
164
+ def server_db(self):
165
+ return self.transitions.server_db
166
+
167
+ @property
168
+ def current_server(self):
169
+ return self.server_db.current
170
+
171
+ def get_failover_rx(self, filehandler: Optional[TextIO]) -> int:
172
+ rx_bytes = self.nm_manager.get_stats_bytes(filehandler)
173
+ if rx_bytes is None:
174
+ return -1
175
+ return rx_bytes
176
+
177
+ def should_failover(self):
178
+ if self._should_failover:
179
+ logger.debug("eduvpn-common reports we should failover")
180
+
181
+ if self._was_tcp:
182
+ logger.debug("TCP was not previously triggered, failover should not continue")
183
+ return False
184
+ return True
185
+
186
+ logger.debug("Failover should not continue")
187
+ return False
188
+
189
+ def reconnect_tcp(self, callback: Callable):
190
+ def on_reconnected(success: bool):
191
+ callback(success)
192
+
193
+ self.reconnect(on_reconnected, prefer_tcp=True)
194
+
195
+ @run_in_background_thread("start-failover")
196
+ def start_failover(self, callback: Callable):
197
+ try:
198
+ rx_bytes_file = self.nm_manager.open_stats_file("rx_bytes")
199
+ if rx_bytes_file is None:
200
+ logger.error("Failed to initialize failover, failed to open rx bytes file")
201
+ callback(False)
202
+ return
203
+ endpoint = self.nm_manager.failover_endpoint_ip
204
+ if endpoint is None:
205
+ logger.error("Failed to initialize failover, failed to get endpoint")
206
+ callback(False)
207
+ return
208
+ mtu = self.nm_manager.mtu
209
+ if mtu is None:
210
+ logger.debug("failed to get MTU for failover, setting MTU to 1000")
211
+ mtu = 1000
212
+ logger.debug(
213
+ f"starting failover with gateway {endpoint} and MTU {mtu} for protocol {self.nm_manager.protocol}"
214
+ )
215
+ failover_delay = float(os.getenv("EDUVPN_FAILOVER_DELAY", 1))
216
+ logger.debug(f"Sleeping for {failover_delay}s to begin failover")
217
+ time.sleep(failover_delay)
218
+ dropped = self.common.start_failover(
219
+ endpoint,
220
+ mtu,
221
+ ReadRxBytes(lambda: self.get_failover_rx(rx_bytes_file)),
222
+ )
223
+
224
+ if dropped:
225
+ logger.debug("Failover exited, connection is dropped")
226
+ callback(True)
227
+ else:
228
+ logger.debug("Failover exited, connection is NOT dropped")
229
+ callback(False)
230
+ except WrappedError as e:
231
+ logger.debug(f"Failed failover, error: {e}")
232
+ callback(False)
233
+
234
+ def change_secure_location(self, country_code: str):
235
+ server = self.server_db.secure_internet
236
+ if server.country_code == country_code:
237
+ return
238
+ self.common.set_secure_location(server.org_id, country_code)
239
+ self.common.set_state(State.MAIN)
240
+
241
+ def go_back(self):
242
+ self.cancel()
243
+ self.common.set_state(State.MAIN)
244
+
245
+ def add(self, server, callback=None):
246
+ self.common.add_server(server.category_id, server.identifier)
247
+ if callback:
248
+ callback(server)
249
+
250
+ def remove(self, server):
251
+ self.common.remove_server(server.category_id, server.identifier)
252
+ # Delete tokens from the keyring
253
+ self.clear_tokens(server.category_id, server.identifier)
254
+ self.common.set_state(State.MAIN)
255
+
256
+ def connect_get_config(self, server, prefer_tcp: bool = False) -> Config:
257
+ # We prefer TCP if the user has set it or UDP is determined to be blocked
258
+ config = self.common.get_config(server.category_id, server.identifier, prefer_tcp)
259
+ return parse_config(config)
260
+
261
+ def clear_tokens(self, server_type: int, server_id: str):
262
+ attributes = {
263
+ "server": server_id,
264
+ "category": str(ServerType(server_type)),
265
+ }
266
+ try:
267
+ cleared = self.keyring.clear(attributes)
268
+ if not cleared:
269
+ logger.debug("Tokens were not cleared")
270
+ except Exception as e:
271
+ logger.debug("Failed clearing tokens with exception")
272
+ logger.debug(e, exc_info=True)
273
+
274
+ def load_tokens(self, server_id: str, server_type: int) -> Optional[str]:
275
+ attributes = {"server": server_id, "category": str(ServerType(server_type))}
276
+ try:
277
+ tokens_json = self.keyring.load(attributes)
278
+ if tokens_json is None:
279
+ logger.debug("No tokens available")
280
+ return None
281
+ tokens = json.loads(tokens_json)
282
+ expires = tokens.get("expires_at", None)
283
+ if expires is None:
284
+ expires = tokens.get("expires", None)
285
+ if expires is None:
286
+ logger.warning("failed to parse expires")
287
+ return None
288
+ d = {
289
+ "access_token": tokens["access"],
290
+ "refresh_token": tokens["refresh"],
291
+ "expires_at": int(expires),
292
+ }
293
+ return json.dumps(d)
294
+ except Exception as e:
295
+ logger.debug("Failed loading tokens with exception:")
296
+ logger.debug(e, exc_info=True)
297
+ return None
298
+
299
+ def save_tokens(self, server_id: str, server_type: int, tokens: str):
300
+ tokens_parsed = parse_tokens(tokens)
301
+ if tokens is None or (tokens_parsed.access == "" and tokens_parsed.refresh == ""):
302
+ logger.warning("Got empty tokens, not saving them to the keyring")
303
+ return
304
+ tokens_dict = {}
305
+ tokens_dict["access"] = tokens_parsed.access
306
+ tokens_dict["refresh"] = tokens_parsed.refresh
307
+ tokens_dict["expires_at"] = str(tokens_parsed.expires)
308
+ attributes = {"server": server_id, "category": str(ServerType(server_type))}
309
+ label = f"{server_id} - OAuth Tokens"
310
+ try:
311
+ self.keyring.save(label, attributes, json.dumps(tokens_dict))
312
+ except Exception as e:
313
+ logger.error("Failed saving tokens with exception:")
314
+ logger.error(e, exc_info=True)
315
+
316
+ def on_proxy_setup(self, fd, peer_ips):
317
+ logger.debug(f"got proxy fd: {fd}, peer_ips: {peer_ips}")
318
+ self._peer_ips_proxy = json.loads(peer_ips)
319
+
320
+ @run_in_background_thread("start-proxy")
321
+ def start_proxy(self, proxy, callback):
322
+ try:
323
+ self.common.start_proxyguard(
324
+ proxy.listen,
325
+ proxy.source_port,
326
+ proxy.peer,
327
+ ProxySetup(self.on_proxy_setup),
328
+ ProxyReady(lambda: callback()),
329
+ )
330
+ except Exception as e:
331
+ handle_exception(self.common, e)
332
+
333
+ def connect(
334
+ self,
335
+ server,
336
+ callback: Optional[Callable] = None,
337
+ prefer_tcp: bool = False,
338
+ ) -> None:
339
+ # Variable to be used as a last resort or for debugging
340
+ # to override the prefer TCP setting
341
+ if os.environ.get("EDUVPN_PREFER_TCP", "0") == "1":
342
+ prefer_tcp = True
343
+ config = self.connect_get_config(server, prefer_tcp=prefer_tcp)
344
+ if not config:
345
+ logger.warning("no configuration available")
346
+ if callback:
347
+ callback(False)
348
+ return
349
+
350
+ self._was_tcp = prefer_tcp or config.protocol == Protocol.WIREGUARDTCP
351
+ self._should_failover = config.should_failover
352
+
353
+ def on_fail():
354
+ if self.common.in_state(State.CONNECTED):
355
+ self.common.set_state(State.DISCONNECTING)
356
+ self.common.set_state(State.DISCONNECTED)
357
+ if callback:
358
+ callback(False)
359
+
360
+ def on_success():
361
+ # failed to disconnected
362
+ if self.common.in_state(State.CONNECTING):
363
+ self.common.set_state(State.CONNECTED)
364
+ if callback:
365
+ callback(True)
366
+
367
+ def final_connected(dropped: bool, error: str = ""):
368
+ # failover reports not dropped, return to connected if we are still in connecting
369
+ if not dropped:
370
+ if self.common.in_state(State.CONNECTING):
371
+ on_success()
372
+ else:
373
+ on_fail()
374
+ return
375
+
376
+ # Connection is dropped!
377
+ def on_reconnected(success: bool):
378
+ if not success:
379
+ handle_exception(self.common, Exception("failed to reconnect with TCP"))
380
+ if success:
381
+ set_failovered(self.common)
382
+ # When an error happens we always set success now as the previous
383
+ # protocol might be connected to
384
+ # TODO: differentiate between disconnect and new connect errors
385
+ on_success()
386
+
387
+ # reconnect with TCP
388
+ self.reconnect_tcp(on_reconnected)
389
+
390
+ def on_connected(success: bool):
391
+ if success:
392
+ # failover should not continue
393
+ if not self.should_failover():
394
+ on_success()
395
+ else:
396
+ set_online_detecting(self.common)
397
+ # failover should start
398
+ self.start_failover(final_connected)
399
+ else:
400
+ on_fail()
401
+
402
+ def on_connect(success: bool):
403
+ if success:
404
+ self.nm_manager.activate_connection(on_connected)
405
+ else:
406
+ on_fail()
407
+
408
+ @run_in_glib_thread
409
+ def connect(config):
410
+ if not self.common.in_state(State.CONNECTING):
411
+ self.common.set_state(State.CONNECTING)
412
+ connection = Connection.parse(config)
413
+ connection.connect(
414
+ self.nm_manager,
415
+ config.default_gateway,
416
+ self.config.allow_wg_lan,
417
+ config.dns_search_domains,
418
+ config.proxy,
419
+ self._peer_ips_proxy,
420
+ on_connect,
421
+ )
422
+ self._peer_ips_proxy = None
423
+
424
+ if config.proxy:
425
+ self.start_proxy(config.proxy, lambda: connect(config))
426
+ else:
427
+ connect(config)
428
+
429
+ def reconnect(self, callback: Optional[Callable] = None, prefer_tcp: bool = False):
430
+ def on_disconnected(success: bool):
431
+ if success:
432
+ self.activate_connection(callback, prefer_tcp=prefer_tcp)
433
+
434
+ # Reconnect
435
+ self.deactivate_connection(on_disconnected)
436
+
437
+ # https://github.com/eduvpn/documentation/blob/v3/API.md#session-expiry
438
+ def renew_session(self, callback: Optional[Callable] = None):
439
+ was_connected = self.common.in_state(State.CONNECTED)
440
+
441
+ @run_in_background_thread("reconnect")
442
+ def reconnect(success: bool = True):
443
+ if not success:
444
+ if callback:
445
+ callback(False)
446
+ return
447
+ # Delete the OAuth access and refresh token
448
+ # Start the OAuth authorization flow
449
+ self.common.renew_session()
450
+ # Automatically reconnect to the server
451
+ self.activate_connection(callback)
452
+
453
+ if was_connected:
454
+ # Call /disconnect and reconnect with callback
455
+ self.deactivate_connection(reconnect)
456
+ else:
457
+ reconnect()
458
+
459
+ def disconnect(self, callback: Optional[Callable] = None) -> None:
460
+ self.nm_manager.deactivate_connection(callback)
461
+
462
+ def set_profile(self, profile: str, connect=False):
463
+ was_connected = self.common.in_state(State.CONNECTED)
464
+
465
+ def do_profile(success: bool = True):
466
+ if not success:
467
+ return
468
+ # Set the profile ID
469
+ self.common.set_profile(profile)
470
+
471
+ # Connect if we should and if we were previously connected
472
+ if connect and was_connected:
473
+ self.activate_connection()
474
+
475
+ # Deactivate connection if we are connected
476
+ # and the connection should be modified
477
+ # the do_profile will be called in the callback
478
+ if was_connected and connect:
479
+ self.deactivate_connection(do_profile)
480
+ else:
481
+ do_profile()
482
+
483
+ def activate_connection(self, callback: Optional[Callable] = None, prefer_tcp: bool = False):
484
+ if (
485
+ not self.common.in_state(State.GOT_CONFIG)
486
+ and not self.common.in_state(State.DISCONNECTED)
487
+ and not self.common.in_state(State.MAIN)
488
+ ):
489
+ if callback:
490
+ logger.error("invalid state to activate connection")
491
+ callback(False)
492
+ return
493
+ if not self.current_server:
494
+ if callback:
495
+ logger.error("failed to get current server")
496
+ callback(False)
497
+ return
498
+
499
+ def on_connected(success: bool):
500
+ if callback:
501
+ callback(success)
502
+
503
+ self.connect(self.current_server, on_connected, prefer_tcp=prefer_tcp)
504
+
505
+ @run_in_background_thread("cleanup")
506
+ def cleanup(self, callback: Optional[Callable] = None):
507
+ # We retry this cleanup 2 times
508
+ retries = 2
509
+
510
+ # Try to cleanup with a number of retries
511
+ for i in range(retries):
512
+ logger.debug("Cleaning up tokens...")
513
+ try:
514
+ self.common.cleanup()
515
+ except Exception as e:
516
+ # We can try again
517
+ if i < retries - 1:
518
+ logger.debug(
519
+ f"Got an error: {str(e)} while cleaning up, try number: {i+1}. This could mean the connection was not fully disconnected yet. Trying again..."
520
+ )
521
+ else:
522
+ # All retries are done
523
+ logger.debug(f"Got an error: {str(e)} while cleaning up, after full retries: {i+1}.")
524
+ else:
525
+ break
526
+ if self.common.in_state(State.DISCONNECTING):
527
+ self.common.set_state(State.DISCONNECTED)
528
+ if callback:
529
+ callback()
530
+
531
+ def deactivate_connection(self, callback: Optional[Callable] = None, cleanup=True) -> None:
532
+ curr = None
533
+ if self.common.in_state(State.CONNECTED):
534
+ curr = State.CONNECTED
535
+
536
+ if self.common.in_state(State.CONNECTING):
537
+ curr = State.CONNECTING
538
+
539
+ if not curr:
540
+ return
541
+ self.common.set_state(State.DISCONNECTING)
542
+
543
+ def on_disconnected(success: bool):
544
+ if success:
545
+
546
+ def on_cleaned():
547
+ self.common.cancel()
548
+ if callback:
549
+ callback(True)
550
+
551
+ if cleanup:
552
+ self.cleanup(on_cleaned)
553
+ else:
554
+ if self.common.in_state(State.DISCONNECTING):
555
+ self.common.set_state(State.DISCONNECTED)
556
+ on_cleaned()
557
+ else:
558
+ try:
559
+ self.common.set_state(curr)
560
+ except WrappedError as e:
561
+ logger.debug(f"set_state error in deactivate connection: {str(e)}")
562
+ if callback:
563
+ callback(False)
564
+
565
+ self.disconnect(on_disconnected)
566
+
567
+ def search_predefined(self, query: str) -> Iterator[Any]:
568
+ return self.server_db.search_predefined(query)
569
+
570
+ def search_custom(self, query: str) -> Iterator[Any]:
571
+ return self.server_db.search_custom(query)
572
+
573
+
574
+ class Application:
575
+ def __init__(self, variant: ApplicationVariant, common: EduVPN) -> None:
576
+ self.variant = variant
577
+ self.nm_manager = nm.NMManager(variant)
578
+ self.common = common
579
+ directory = variant.config_prefix
580
+ self.config = Configuration.load(directory)
581
+ self.model = ApplicationModel(common, self.config, variant, self.nm_manager)
582
+
583
+ signal.signal(signal.SIGINT, lambda s, f: self.cleanup_sigint(s, f))
584
+
585
+ def cleanup_sigint(self, _signal, _frame):
586
+ self.model.cancel()
587
+ self.common.deregister()
588
+
589
+ def on_network_update_callback(self, state, initial=False):
590
+ try:
591
+ if state == nm.ConnectionState.CONNECTED:
592
+ if (
593
+ not self.common.in_state(State.DISCONNECTED)
594
+ and not self.common.in_state(State.GOT_CONFIG)
595
+ and not initial
596
+ ):
597
+ return
598
+ if not initial:
599
+ self.common.set_state(State.CONNECTING)
600
+ # Already connected
601
+ self.common.set_state(State.CONNECTED)
602
+ elif state == nm.ConnectionState.CONNECTING:
603
+ self.common.set_state(State.CONNECTING)
604
+ elif state == nm.ConnectionState.DISCONNECTED:
605
+ if not self.common.in_state(State.CONNECTED) and not self.common.in_state(State.CONNECTING):
606
+ return
607
+ self.common.set_state(State.DISCONNECTING)
608
+ self.model.cancel()
609
+ self.model.cleanup()
610
+ self.model.disconnect()
611
+ except Exception as e:
612
+ logger.debug(f"error occurred: {str(e)}")
613
+ return
614
+
615
+ def initialize_network(self, needs_update=True) -> None:
616
+ """
617
+ Determine the current network state.
618
+ """
619
+ # Check if a previous network configuration exists.
620
+ uuid = self.nm_manager.existing_connection
621
+ if uuid:
622
+ self.on_network_update_callback(self.nm_manager.connection_state, needs_update)
623
+
624
+ @run_in_background_thread("on-network-update")
625
+ def update(state):
626
+ self.on_network_update_callback(state, False)
627
+
628
+ self.nm_manager.subscribe_to_status_changes(update)