6
6
__author__ = 'Simon Robinson'
7
7
__copyright__ = 'Copyright (c) 2023 Simon Robinson'
8
8
__license__ = 'Apache 2.0'
9
- __version__ = '2023-09-03 ' # ISO 8601 (YYYY-MM-DD)
9
+ __version__ = '2023-09-06 ' # ISO 8601 (YYYY-MM-DD)
10
10
11
11
import abc
12
12
import argparse
@@ -405,15 +405,59 @@ def save(store_id, config_dict, create_secret=True):
405
405
Log .error ('Unable to get AWS SDK client; cannot cache credentials to AWS Secrets Manager' )
406
406
407
407
408
+ class ConcurrentConfigParser :
409
+ """Helper wrapper to add locking to a ConfigParser object (note: only wraps the methods used in this script)"""
410
+
411
+ def __init__ (self ):
412
+ self .config = configparser .ConfigParser ()
413
+ self .lock = threading .Lock ()
414
+
415
+ def read (self , filename ):
416
+ with self .lock :
417
+ self .config .read (filename )
418
+
419
+ def sections (self ):
420
+ with self .lock :
421
+ return self .config .sections ()
422
+
423
+ def add_section (self , section ):
424
+ with self .lock :
425
+ self .config .add_section (section )
426
+
427
+ def get (self , section , option , fallback = None ):
428
+ with self .lock :
429
+ return self .config .get (section , option , fallback = fallback )
430
+
431
+ def getint (self , section , option , fallback = None ):
432
+ with self .lock :
433
+ return self .config .getint (section , option , fallback = fallback )
434
+
435
+ def getboolean (self , section , option , fallback = None ):
436
+ with self .lock :
437
+ return self .config .getboolean (section , option , fallback = fallback )
438
+
439
+ def set (self , section , option , value ):
440
+ with self .lock :
441
+ self .config .set (section , option , value )
442
+
443
+ def remove_option (self , section , option ):
444
+ with self .lock :
445
+ self .config .remove_option (section , option )
446
+
447
+ def write (self , file ):
448
+ with self .lock :
449
+ self .config .write (file )
450
+
451
+ def items (self ):
452
+ with self .lock :
453
+ return self .config .items () # used in read_dict when saving to cache store
454
+
455
+
408
456
class AppConfig :
409
457
"""Helper wrapper around ConfigParser to cache servers/accounts, and avoid writing to the file until necessary"""
410
458
411
459
_PARSER = None
412
- _LOADED = False
413
-
414
- _GLOBALS = None
415
- _SERVERS = []
416
- _ACCOUNTS = []
460
+ _PARSER_LOCK = threading .Lock ()
417
461
418
462
# note: removing the unencrypted version of `client_secret_encrypted` is not automatic with --cache-store (see docs)
419
463
_CACHED_OPTION_KEYS = ['token_salt' , 'access_token' , 'access_token_expiry' , 'refresh_token' , 'last_activity' ,
@@ -424,38 +468,26 @@ class AppConfig:
424
468
425
469
@staticmethod
426
470
def _load ():
427
- AppConfig .unload ()
428
- AppConfig ._PARSER = configparser .ConfigParser ()
429
- AppConfig ._PARSER .read (CONFIG_FILE_PATH )
430
-
431
- config_sections = AppConfig ._PARSER .sections ()
432
- if APP_SHORT_NAME in config_sections :
433
- AppConfig ._GLOBALS = AppConfig ._PARSER [APP_SHORT_NAME ]
434
- else :
435
- AppConfig ._GLOBALS = configparser .SectionProxy (AppConfig ._PARSER , APP_SHORT_NAME )
471
+ config_parser = ConcurrentConfigParser ()
472
+ config_parser .read (CONFIG_FILE_PATH )
436
473
437
474
# cached account credentials can be stored in the configuration file (default) or, via `--cache-store`, a
438
475
# separate local file or external service (such as a secrets manager) - we combine these sources at load time
439
476
if CACHE_STORE != CONFIG_FILE_PATH :
440
477
# it would be cleaner to avoid specific options here, but best to load unexpected sections only when enabled
441
- allow_catch_all_accounts = AppConfig ._GLOBALS .getboolean ('allow_catch_all_accounts' , fallback = False )
478
+ allow_catch_all_accounts = config_parser .getboolean (APP_SHORT_NAME , 'allow_catch_all_accounts' ,
479
+ fallback = False )
442
480
443
481
cache_file_parser = AppConfig ._load_cache (CACHE_STORE )
444
482
cache_file_accounts = [s for s in cache_file_parser .sections () if '@' in s ]
445
483
for account in cache_file_accounts :
446
- if allow_catch_all_accounts and account not in AppConfig . _PARSER .sections (): # missing sub-accounts
447
- AppConfig . _PARSER .add_section (account )
484
+ if allow_catch_all_accounts and account not in config_parser .sections (): # missing sub-accounts
485
+ config_parser .add_section (account )
448
486
for option in cache_file_parser .options (account ):
449
487
if option in AppConfig ._CACHED_OPTION_KEYS :
450
- AppConfig ._PARSER .set (account , option , cache_file_parser .get (account , option ))
451
-
452
- if allow_catch_all_accounts :
453
- config_sections = AppConfig ._PARSER .sections () # new sections may have been added
488
+ config_parser .set (account , option , cache_file_parser .get (account , option ))
454
489
455
- AppConfig ._SERVERS = [s for s in config_sections if CONFIG_SERVER_MATCHER .match (s )]
456
- AppConfig ._ACCOUNTS = [s for s in config_sections if '@' in s ]
457
-
458
- AppConfig ._LOADED = True
490
+ return config_parser
459
491
460
492
@staticmethod
461
493
def _load_cache (cache_store_identifier ):
@@ -469,59 +501,47 @@ def _load_cache(cache_store_identifier):
469
501
470
502
@staticmethod
471
503
def get ():
472
- if not AppConfig ._LOADED :
473
- AppConfig ._load ()
474
- return AppConfig ._PARSER
504
+ with AppConfig ._PARSER_LOCK :
505
+ if AppConfig ._PARSER is None :
506
+ AppConfig ._PARSER = AppConfig ._load ()
507
+ return AppConfig ._PARSER
475
508
476
509
@staticmethod
477
510
def unload ():
478
- AppConfig ._PARSER = None
479
- AppConfig ._LOADED = False
480
-
481
- AppConfig ._GLOBALS = None
482
- AppConfig ._SERVERS = []
483
- AppConfig ._ACCOUNTS = []
484
-
485
- @staticmethod
486
- def reload ():
487
- AppConfig .unload ()
488
- return AppConfig .get ()
511
+ with AppConfig ._PARSER_LOCK :
512
+ AppConfig ._PARSER = None
489
513
490
514
@staticmethod
491
- def globals ():
492
- AppConfig .get () # make sure config is loaded
493
- return AppConfig ._GLOBALS
515
+ def get_global (name , fallback ):
516
+ return AppConfig .get ().getboolean (APP_SHORT_NAME , name , fallback )
494
517
495
518
@staticmethod
496
519
def servers ():
497
- AppConfig .get () # make sure config is loaded
498
- return AppConfig ._SERVERS
520
+ return [s for s in AppConfig .get ().sections () if CONFIG_SERVER_MATCHER .match (s )]
499
521
500
522
@staticmethod
501
523
def accounts ():
502
- AppConfig .get () # make sure config is loaded
503
- return AppConfig ._ACCOUNTS
504
-
505
- @staticmethod
506
- def add_account (username ):
507
- AppConfig ._PARSER .add_section (username )
508
- AppConfig ._ACCOUNTS = [s for s in AppConfig ._PARSER .sections () if '@' in s ]
524
+ return [s for s in AppConfig .get ().sections () if '@' in s ]
509
525
510
526
@staticmethod
511
527
def save ():
512
- if AppConfig ._LOADED :
528
+ with AppConfig ._PARSER_LOCK :
529
+ if AppConfig ._PARSER is None : # intentionally using _PARSER not get() so we don't (re-)load if unloaded
530
+ return
531
+
513
532
if CACHE_STORE != CONFIG_FILE_PATH :
514
533
# in `--cache-store` mode we ignore everything except _CACHED_OPTION_KEYS (OAuth 2.0 tokens, etc)
515
534
output_config_parser = configparser .ConfigParser ()
516
535
output_config_parser .read_dict (AppConfig ._PARSER ) # a deep copy of the current configuration
536
+ config_accounts = [s for s in output_config_parser .sections () if '@' in s ]
517
537
518
- for account in AppConfig . _ACCOUNTS :
538
+ for account in config_accounts :
519
539
for option in output_config_parser .options (account ):
520
540
if option not in AppConfig ._CACHED_OPTION_KEYS :
521
541
output_config_parser .remove_option (account , option )
522
542
523
543
for section in output_config_parser .sections ():
524
- if section not in AppConfig . _ACCOUNTS or len (output_config_parser .options (section )) <= 0 :
544
+ if section not in config_accounts or len (output_config_parser .options (section )) <= 0 :
525
545
output_config_parser .remove_section (section )
526
546
527
547
AppConfig ._save_cache (CACHE_STORE , output_config_parser )
@@ -557,10 +577,11 @@ def get_oauth2_credentials(username, password, recurse_retries=True):
557
577
if invalid). Returns either (True, '[OAuth2 string for authentication]') or (False, '[Error message]')"""
558
578
559
579
# we support broader catch-all account names (e.g., `@domain.com` / `@`) if enabled
560
- valid_accounts = [username in AppConfig .accounts ()]
561
- if AppConfig .globals ().getboolean ('allow_catch_all_accounts' , fallback = False ):
580
+ config_accounts = AppConfig .accounts ()
581
+ valid_accounts = [username in config_accounts ]
582
+ if AppConfig .get_global ('allow_catch_all_accounts' , fallback = False ):
562
583
user_domain = '@%s' % username .split ('@' )[- 1 ]
563
- valid_accounts .extend ([account in AppConfig . accounts () for account in [user_domain , '@' ]])
584
+ valid_accounts .extend ([account in config_accounts for account in [user_domain , '@' ]])
564
585
565
586
if not any (valid_accounts ):
566
587
Log .error ('Proxy config file entry missing for account' , username , '- aborting login' )
@@ -572,7 +593,7 @@ def get_oauth2_credentials(username, password, recurse_retries=True):
572
593
573
594
def get_account_with_catch_all_fallback (option ):
574
595
fallback = None
575
- if AppConfig .globals (). getboolean ('allow_catch_all_accounts' , fallback = False ):
596
+ if AppConfig .get_global ('allow_catch_all_accounts' , fallback = False ):
576
597
fallback = config .get (user_domain , option , fallback = config .get ('@' , option , fallback = None ))
577
598
return config .get (username , option , fallback = fallback )
578
599
@@ -614,7 +635,7 @@ def get_account_with_catch_all_fallback(option):
614
635
615
636
# try reloading remotely cached tokens if possible
616
637
if not access_token and CACHE_STORE != CONFIG_FILE_PATH and recurse_retries :
617
- AppConfig .reload ()
638
+ AppConfig .unload ()
618
639
return OAuth2Helper .get_oauth2_credentials (username , password , recurse_retries = False )
619
640
620
641
# we hash locally-stored tokens with the given password
@@ -682,8 +703,8 @@ def get_account_with_catch_all_fallback(option):
682
703
oauth2_flow , username , password )
683
704
684
705
access_token = response ['access_token' ]
685
- if not config .has_section ( username ):
686
- AppConfig . add_account (username ) # in wildcard mode the section may not yet exist
706
+ if username not in config .sections ( ):
707
+ config . add_section (username ) # in catch-all mode the section may not yet exist
687
708
REQUEST_QUEUE .put (MENU_UPDATE ) # make sure the menu shows the newly-added account
688
709
config .set (username , 'token_salt' , token_salt )
689
710
config .set (username , 'access_token' , OAuth2Helper .encrypt (fernet , access_token ))
@@ -695,7 +716,7 @@ def get_account_with_catch_all_fallback(option):
695
716
Log .info ('Warning: no refresh token returned for' , username , '- you will need to re-authenticate' ,
696
717
'each time the access token expires (does your `oauth2_scope` value allow `offline` use?)' )
697
718
698
- if AppConfig .globals (). getboolean ('encrypt_client_secret_on_first_use' , fallback = False ):
719
+ if AppConfig .get_global ('encrypt_client_secret_on_first_use' , fallback = False ):
699
720
if client_secret :
700
721
# note: save to the `username` entry even if `user_domain` exists, avoiding conflicts when using
701
722
# incompatible `encrypt_client_secret_on_first_use` and `allow_catch_all_accounts` options
@@ -712,8 +733,8 @@ def get_account_with_catch_all_fallback(option):
712
733
except InvalidToken as e :
713
734
# if invalid details are the reason for failure we remove our cached version and re-authenticate - this can
714
735
# be disabled by a configuration setting, but note that we always remove credentials on 400 Bad Request
715
- if e .args == (400 , APP_PACKAGE ) or AppConfig .globals (). getboolean ('delete_account_token_on_password_error' ,
716
- fallback = True ):
736
+ if e .args == (400 , APP_PACKAGE ) or AppConfig .get_global ('delete_account_token_on_password_error' ,
737
+ fallback = True ):
717
738
config .remove_option (username , 'token_salt' )
718
739
config .remove_option (username , 'access_token' )
719
740
config .remove_option (username , 'access_token_expiry' )
@@ -2290,11 +2311,11 @@ def macos_nsworkspace_notification_listener_(self, notification):
2290
2311
Log .info ('Received power off notification; exiting' , APP_NAME )
2291
2312
self .exit (self .icon )
2292
2313
2314
+ # noinspection PyDeprecation
2293
2315
def create_icon (self ):
2294
2316
# temporary fix for pystray <= 0.19.4 incompatibility with PIL 10.0.0+; fixed once pystray PR #147 is released
2295
2317
with warnings .catch_warnings ():
2296
2318
warnings .simplefilter ('ignore' , DeprecationWarning )
2297
- # noinspection PyDeprecation
2298
2319
pystray_version = pkg_resources .get_distribution ('pystray' ).version
2299
2320
pillow_version = pkg_resources .get_distribution ('pillow' ).version
2300
2321
if pkg_resources .parse_version (pystray_version ) <= pkg_resources .parse_version ('0.19.4' ) and \
@@ -2382,7 +2403,7 @@ def create_config_menu(self):
2382
2403
if len (config_accounts ) <= 0 :
2383
2404
items .append (pystray .MenuItem (' No accounts configured' , None , enabled = False ))
2384
2405
else :
2385
- catch_all_enabled = AppConfig .globals (). getboolean ('allow_catch_all_accounts' , fallback = False )
2406
+ catch_all_enabled = AppConfig .get_global ('allow_catch_all_accounts' , fallback = False )
2386
2407
catch_all_accounts = []
2387
2408
for account in config_accounts :
2388
2409
if account .startswith ('@' ) and catch_all_enabled :
@@ -2772,7 +2793,9 @@ def load_and_start_servers(self, icon=None, reload=True):
2772
2793
# we allow reloading, so must first stop any existing servers
2773
2794
self .stop_servers ()
2774
2795
Log .info ('Initialising' , APP_NAME , '(version %s)' % __version__ , 'from config file' , CONFIG_FILE_PATH )
2775
- config = AppConfig .reload () if reload else AppConfig .get ()
2796
+ if reload :
2797
+ AppConfig .unload ()
2798
+ config = AppConfig .get ()
2776
2799
2777
2800
# load server types and configurations
2778
2801
server_load_error = False
0 commit comments