diff --git a/docs/configuration.rst b/docs/configuration.rst index 2aa6416..be4bcf4 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -122,3 +122,23 @@ Also change your i3wm config to the following: position top workspace_buttons yes } + +Settings that require credentials can utilize the keyring module to keep sensitive information out of config files. +To take advantage of this feature, simply use the setting_util.py script to set the credentials for a module. Once this +is done you can add the module to your config without specifying the credentials, eg: + +:: + + # Use the default keyring to retrieve credentials. To determine which backend is the default on your system, run + # python -c 'import keyring; print(keyring.get_keyring())' + status.register('github') + +If you don't want to use the default you can set a specific keyring like so: + +:: + + from keyring.backends.file import PlaintextKeyring + status.register('github', keyring_backend=PlaintextKeyring()) + + +i3pystatus will locate and set the credentials during the module loading process. Currently supported credentals are "password", "email" and "username". \ No newline at end of file diff --git a/docs/module.rst b/docs/module.rst index 6b195f3..31be7be 100644 --- a/docs/module.rst +++ b/docs/module.rst @@ -14,6 +14,9 @@ tools for this which make this even easier: periodically. - Settings (already built into above classes) allow you to easily specify user-modifiable attributes of your class for configuration. +- For modules that require credentials, it is recommended to add a + keyring_backend setting to allow users to specify their own backends + for retrieving sensitive credentials. Required settings and default values are also handled. diff --git a/i3pystatus/core/settings.py b/i3pystatus/core/settings.py index 6d67ae6..9406b82 100644 --- a/i3pystatus/core/settings.py +++ b/i3pystatus/core/settings.py @@ -2,10 +2,10 @@ from i3pystatus.core.util import KeyConstraintDict from i3pystatus.core.exceptions import ConfigKeyError, ConfigMissingError import inspect import logging +import getpass class SettingsBase: - """ Support class for providing a nice and flexible settings interface @@ -18,6 +18,8 @@ class SettingsBase: Settings are stored as attributes of self. """ + __PROTECTED_SETTINGS = ["password", "email", "username"] + settings = ( ("log_level", "Set to true to log error to .i3pystatus- file"), ) @@ -44,21 +46,25 @@ class SettingsBase: return kwargs def merge_with_parents_settings(): - settings = tuple() - # getmro returns base classes according to Method Resolution Order for cls in inspect.getmro(self.__class__): if hasattr(cls, "settings"): settings = settings + cls.settings return settings + self.__name__ = "{}.{}".format( + self.__module__, self.__class__.__name__) + settings = merge_with_parents_settings() settings = self.flatten_settings(settings) sm = KeyConstraintDict(settings, self.required) settings_source = get_argument_dict(args, kwargs) + protected = self.get_protected_settings(settings_source) + settings_source.update(protected) + try: sm.update(settings_source) except KeyError as exc: @@ -70,13 +76,48 @@ class SettingsBase: raise ConfigMissingError( type(self).__name__, missing=exc.keys) from exc - self.__name__ = "{}.{}".format( - self.__module__, self.__class__.__name__) - self.logger = logging.getLogger(self.__name__) self.logger.setLevel(self.log_level) self.init() + def get_protected_settings(self, settings_source): + """ + Attempt to retrieve protected settings from keyring if they are not already set. + """ + user_backend = settings_source.get('keyring_backend') + found_settings = dict() + for setting_name in self.__PROTECTED_SETTINGS: + # Nothing to do if the setting is already defined. + if settings_source.get(setting_name): + continue + + setting = None + identifier = "%s.%s" % (self.__name__, setting_name) + if hasattr(self, 'required') and setting_name in getattr(self, 'required'): + setting = self.get_setting_from_keyring(identifier, user_backend) + elif hasattr(self, setting_name): + setting = self.get_setting_from_keyring(identifier, user_backend) + if setting: + found_settings.update({setting_name: setting}) + return found_settings + + def get_setting_from_keyring(self, setting_identifier, keyring_backend=None): + """ + Retrieves a protected setting from keyring + :param setting_identifier: must be in the format package.module.Class.setting + """ + # If a custom keyring backend has been defined, use it. + if keyring_backend: + return keyring_backend.get_password(setting_identifier, getpass.getuser()) + + # Otherwise try and use default keyring. + try: + import keyring + except ImportError: + pass + else: + return keyring.get_password(setting_identifier, getpass.getuser()) + def init(self): """Convenience method which is called after all settings are set diff --git a/i3pystatus/github.py b/i3pystatus/github.py index a40c306..bfcfac8 100644 --- a/i3pystatus/github.py +++ b/i3pystatus/github.py @@ -23,11 +23,13 @@ class Github(IntervalModule): password = '' format = '{unread}' interval = 600 + keyring_backend = None on_leftclick = 'open_github' settings = ( ('format', 'format string'), + ('keyring_backend', 'alternative keyring backend for retrieving credentials'), ('unread_marker', 'sets the string that the "unread" formatter shows when there are pending notifications'), ("username", ""), ("password", ""), diff --git a/i3pystatus/mail/imap.py b/i3pystatus/mail/imap.py index 94f3b20..6f91937 100644 --- a/i3pystatus/mail/imap.py +++ b/i3pystatus/mail/imap.py @@ -16,10 +16,12 @@ class IMAP(Backend): settings = ( "host", "port", "username", "password", + ('keyring_backend', 'alternative keyring backend for retrieving credentials'), "ssl", "mailbox", ) required = ("host", "username", "password") + keyring_backend = None port = 993 ssl = True diff --git a/i3pystatus/modsde.py b/i3pystatus/modsde.py index ff5f9b0..acb3a2d 100644 --- a/i3pystatus/modsde.py +++ b/i3pystatus/modsde.py @@ -20,10 +20,12 @@ class ModsDeChecker(IntervalModule): settings = ( ("format", """Use {unread} as the formatter for number of unread posts"""), + ('keyring_backend', 'alternative keyring backend for retrieving credentials'), ("offset", """subtract number of posts before output"""), "color", "username", "password" ) required = ("username", "password") + keyring_backend = None color = "#7181fe" offset = 0 diff --git a/i3pystatus/pyload.py b/i3pystatus/pyload.py index f960114..a528eab 100644 --- a/i3pystatus/pyload.py +++ b/i3pystatus/pyload.py @@ -29,9 +29,11 @@ class pyLoad(IntervalModule): "format", "captcha_true", "captcha_false", "download_true", "download_false", - "username", "password" + "username", "password", + ('keyring_backend', 'alternative keyring backend for retrieving credentials'), ) required = ("username", "password") + keyring_backend = None address = "http://127.0.0.1:8000" format = "{captcha} {progress_all:.1f}% {speed:.1f} kb/s" diff --git a/i3pystatus/reddit.py b/i3pystatus/reddit.py index f448ecd..3535fbd 100644 --- a/i3pystatus/reddit.py +++ b/i3pystatus/reddit.py @@ -35,6 +35,7 @@ class Reddit(IntervalModule): ("format", "Format string used for output."), ("username", "Reddit username."), ("password", "Reddit password."), + ('keyring_backend', 'alternative keyring backend for retrieving credentials'), ("subreddit", "Subreddit to monitor. Uses frontpage if unspecified."), ("sort_by", "'hot', 'new', 'rising', 'controversial', or 'top'."), ("color", "Standard color."), @@ -48,6 +49,7 @@ class Reddit(IntervalModule): format = "[{submission_subreddit}] {submission_title} ({submission_domain})" username = "" password = "" + keyring_backend = None subreddit = "" sort_by = "hot" color = "#FFFFFF" diff --git a/i3pystatus/whosonlocation.py b/i3pystatus/whosonlocation.py index dad46dc..e866fc3 100644 --- a/i3pystatus/whosonlocation.py +++ b/i3pystatus/whosonlocation.py @@ -5,7 +5,6 @@ from bs4 import BeautifulSoup class WhosOnLocation(): - email = None password = None session = None @@ -68,9 +67,11 @@ class WOL(IntervalModule): password = None settings = ( + ('keyring_backend', 'alternative keyring backend for retrieving credentials'), 'email', 'password' ) + keyring_backend = None color_on_site = '#00FF00' color_off_site = '#ff0000' diff --git a/setting_util.py b/setting_util.py new file mode 100755 index 0000000..97cf663 --- /dev/null +++ b/setting_util.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python +import glob +import inspect +import os +import keyring +import getpass +import sys +import signal +from i3pystatus import Module, SettingsBase +from i3pystatus.core import ClassFinder +from collections import defaultdict, OrderedDict + +def signal_handler(signal, frame): + sys.exit(0) +signal.signal(signal.SIGINT, signal_handler) + + +def get_int_in_range(prompt, _range): + while True: + answer = input(prompt) + try: + n = int(answer.strip()) + if n in _range: + return n + else: + print("Value out of range!") + except ValueError: + print("Invalid input!") + +modules = [os.path.basename(m.replace('.py', '')) + for m in glob.glob(os.path.join(os.path.dirname(__file__), "i3pystatus", "*.py")) + if not os.path.basename(m).startswith('_')] + +protected_settings = SettingsBase._SettingsBase__PROTECTED_SETTINGS +class_finder = ClassFinder(Module) +credential_modules = defaultdict(dict) +for module_name in modules: + try: + module = class_finder.get_module(module_name) + clazz = class_finder.get_class(module) + members = [m[0] for m in inspect.getmembers(clazz) if not m[0].startswith('_')] + if any([hasattr(clazz, setting) for setting in protected_settings]): + credential_modules[clazz.__name__]['credentials'] = list(set(protected_settings) & set(members)) + credential_modules[clazz.__name__]['key'] = "%s.%s" % (clazz.__module__, clazz.__name__) + elif hasattr(clazz, 'required'): + protected = [] + required = getattr(clazz, 'required') + for setting in protected_settings: + if setting in required: + protected.append(setting) + if protected: + credential_modules[clazz.__name__]['credentials'] = protected + credential_modules[clazz.__name__]['key'] = "%s.%s" % (clazz.__module__, clazz.__name__) + + except ImportError: + continue + +choices = [k for k in credential_modules.keys()] +for idx, module in enumerate(choices, start=1): + print("%s - %s" % (idx, module)) + +index = get_int_in_range("Choose module:\n> ", range(1, len(choices) + 1)) +module_name = choices[index - 1] +module = credential_modules[module_name] + +for idx, setting in enumerate(module['credentials'], start=1): + print("%s - %s" % (idx, setting)) + +choices = module['credentials'] +index = get_int_in_range("Choose setting for %s:\n> " % module_name, range(1, len(choices) + 1)) +setting = choices[index - 1] + +answer = getpass.getpass("Enter value for %s:\n> " % setting) +answer2 = getpass.getpass("Re-enter value\n> ") +if answer == answer2: + key = "%s.%s" % (module['key'], setting) + keyring.set_password(key, getpass.getuser(), answer) + print("%s set!" % setting) +else: + print("Values don't match - nothing set.")