From 0afa8547636ae272f0d8033bb985388db1381e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Wed, 25 Oct 2017 17:16:40 +0200 Subject: [PATCH] Support a centralized plugin blacklist (opt-in) A centralized plugin blacklist read from plugins.octoprint.org on server start now allows us to stop plugins from being loaded that are known to cause severe issues with OctoPrint's regular operation. Blacklist entries can be restricted for specific plugin versions & OctoPrint versions, allowing for very granular control of any kind of blocking. Additionally users may disable blacklist processing in general (an opt-in wizard and a new section in the settings have been added) and at server start via the new --ignore-blacklist parameter available for "octoprint serve" and "octoprint daemon". If a plugin is blacklisted, OctoPrint will not even import the plugin module in question (if only a plugin key is specified OR a key and a version and the plugin's version is already known during import time, which is the case for plugins loaded from entry points) or at the very least stop the plugin from being enabled (if a plugin key and a version is specified and the plugin's version is only known after loading, which is the case for plugins loaded from directories). --- src/octoprint/__init__.py | 166 +++++++++++++++++- src/octoprint/cli/__init__.py | 5 +- src/octoprint/cli/plugins.py | 4 +- src/octoprint/cli/server.py | 52 +++--- src/octoprint/plugin/__init__.py | 5 +- src/octoprint/plugin/core.py | 60 +++++-- src/octoprint/plugins/corewizard/__init__.py | 22 ++- .../corewizard/static/css/corewizard.css | 4 + .../corewizard/static/js/corewizard.js | 83 +++++++++ .../corewizard_pluginblacklist_wizard.jinja2 | 26 +++ .../plugins/pluginmanager/__init__.py | 75 ++------ .../pluginmanager/static/js/pluginmanager.js | 35 +++- .../templates/pluginmanager_settings.jinja2 | 2 +- src/octoprint/server/__init__.py | 22 +-- src/octoprint/server/api/settings.py | 14 ++ src/octoprint/settings.py | 5 + .../static/js/app/viewmodels/settings.js | 4 + .../templates/dialogs/settings/server.jinja2 | 4 + .../server/serverPluginBlacklist.jinja2 | 11 ++ .../serverPluginBlacklistDescription.jinja2 | 5 + .../serverPluginBlacklistEnabled.jinja2 | 8 + .../server/serverPluginBlacklistTtl.jinja2 | 9 + .../server/serverPluginBlacklistUrl.jinja2 | 6 + src/octoprint/util/version.py | 88 ++++++++++ 24 files changed, 588 insertions(+), 127 deletions(-) create mode 100644 src/octoprint/plugins/corewizard/static/css/corewizard.css create mode 100644 src/octoprint/plugins/corewizard/templates/corewizard_pluginblacklist_wizard.jinja2 create mode 100644 src/octoprint/templates/snippets/settings/server/serverPluginBlacklist.jinja2 create mode 100644 src/octoprint/templates/snippets/settings/server/serverPluginBlacklistDescription.jinja2 create mode 100644 src/octoprint/templates/snippets/settings/server/serverPluginBlacklistEnabled.jinja2 create mode 100644 src/octoprint/templates/snippets/settings/server/serverPluginBlacklistTtl.jinja2 create mode 100644 src/octoprint/templates/snippets/settings/server/serverPluginBlacklistUrl.jinja2 create mode 100644 src/octoprint/util/version.py diff --git a/src/octoprint/__init__.py b/src/octoprint/__init__.py index 785a5e5c..e2d32cfc 100644 --- a/src/octoprint/__init__.py +++ b/src/octoprint/__init__.py @@ -57,8 +57,9 @@ class FatalStartupError(BaseException): def init_platform(basedir, configfile, use_logging_file=True, logging_file=None, logging_config=None, debug=False, verbosity=0, uncaught_logger=None, - uncaught_handler=None, safe_mode=False, after_preinit_logging=None, + uncaught_handler=None, safe_mode=False, ignore_blacklist=False, after_preinit_logging=None, after_settings=None, after_logging=None, after_safe_mode=None, + after_event_manager=None, after_connectivity_checker=None, after_plugin_manager=None): kwargs = dict() @@ -94,13 +95,28 @@ def init_platform(basedir, configfile, use_logging_file=True, logging_file=None, if callable(after_safe_mode): after_safe_mode(**kwargs) - plugin_manager = init_pluginsystem(settings, safe_mode=safe_mode) + event_manager = init_event_manager(settings) + + kwargs["event_manager"] = event_manager + if callable(after_event_manager): + after_event_manager(**kwargs) + + connectivity_checker = init_connectivity_checker(settings, event_manager) + + kwargs["connectivity_checker"] = connectivity_checker + if callable(after_connectivity_checker): + after_connectivity_checker(**kwargs) + + plugin_manager = init_pluginsystem(settings, + safe_mode=safe_mode, + ignore_blacklist=ignore_blacklist, + connectivity_checker=connectivity_checker) kwargs["plugin_manager"] = plugin_manager if callable(after_plugin_manager): after_plugin_manager(**kwargs) - return settings, logger, safe_mode, plugin_manager + return settings, logger, safe_mode, event_manager, connectivity_checker, plugin_manager def init_settings(basedir, configfile): @@ -281,17 +297,21 @@ def set_logging_config(config, debug, verbosity, uncaught_logger, uncaught_handl return logger -def init_pluginsystem(settings, safe_mode=False): +def init_pluginsystem(settings, safe_mode=False, ignore_blacklist=True, connectivity_checker=None): """Initializes the plugin manager based on the settings.""" import os - logger = log.getLogger(__name__) + logger = log.getLogger(__name__ + ".startup") plugin_folders = [(os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)), "plugins")), True), settings.getBaseFolder("plugins")] plugin_entry_points = ["octoprint.plugin"] plugin_disabled_list = settings.get(["plugins", "_disabled"]) + + plugin_blacklist = [] + if not ignore_blacklist and settings.getBoolean(["server", "pluginBlacklist", "enabled"]): + plugin_blacklist = get_plugin_blacklist(settings, connectivity_checker=connectivity_checker) plugin_validators = [] if safe_mode: @@ -311,13 +331,14 @@ def init_pluginsystem(settings, safe_mode=False): plugin_folders=plugin_folders, plugin_entry_points=plugin_entry_points, plugin_disabled_list=plugin_disabled_list, + plugin_blacklist=plugin_blacklist, plugin_validators=plugin_validators) settings_overlays = dict() disabled_from_overlays = dict() def handle_plugin_loaded(name, plugin): - if hasattr(plugin.instance, "__plugin_settings_overlay__"): + if plugin.instance and hasattr(plugin.instance, "__plugin_settings_overlay__"): plugin.needs_restart = True # plugin has a settings overlay, inject it @@ -356,7 +377,7 @@ def init_pluginsystem(settings, safe_mode=False): if not addon in already_processed and not addon in disabled_list: disabled_list.append(addon) - logger.info("Disabling plugin {} as defined by plugin {} through settings overlay".format(addon, name)) + logger.info("Disabling plugin {} as defined by plugin {}".format(addon, name)) already_processed.append(name) def handle_plugin_enabled(name, plugin): @@ -370,6 +391,137 @@ def init_pluginsystem(settings, safe_mode=False): pm.reload_plugins(startup=True, initialize_implementations=False) return pm + +def get_plugin_blacklist(settings, connectivity_checker=None): + import requests + import os + import time + import yaml + + from octoprint.util import bom_aware_open + from octoprint.util.version import is_octoprint_compatible + + logger = log.getLogger(__name__ + ".startup") + + if connectivity_checker is not None and not connectivity_checker.online: + logger.info("We don't appear to be online, not fetching plugin blacklist") + return [] + + def format_blacklist(entries): + format_entry = lambda x: "{} ({})".format(x[0], x[1]) if isinstance(x, (list, tuple)) and len(x) == 2 \ + else "{} (any)".format(x) + return ", ".join(map(format_entry, entries)) + + def process_blacklist(entries): + result = [] + + if not isinstance(entries, list): + return result + + for entry in entries: + if not "plugin" in entry: + continue + + if "octoversions" in entry and not is_octoprint_compatible(*entry["octoversions"]): + continue + + if "version" in entry: + logger.debug("Blacklisted plugin: {}, version: {}".format(entry["plugin"], entry["version"])) + result.append((entry["plugin"], entry["version"])) + elif "versions" in entry: + logger.debug("Blacklisted plugin: {}, versions: {}".format(entry["plugin"], ", ".join(entry["versions"]))) + for version in entry["versions"]: + result.append((entry["plugin"], version)) + else: + logger.debug("Blacklisted plugin: {}".format(entry["plugin"])) + result.append(entry["key"]) + + return result + + def fetch_blacklist_from_cache(path, ttl): + if not os.path.isfile(path): + return None + + if os.stat(path).st_mtime + ttl < time.time(): + return None + + with bom_aware_open(path, encoding="utf-8", mode="r") as f: + result = yaml.safe_load(f) + + if isinstance(result, list): + return result + + def fetch_blacklist_from_url(url, timeout=3, cache=None): + result = [] + try: + r = requests.get(url, timeout=timeout) + result = process_blacklist(r.json()) + + if cache is not None: + try: + with bom_aware_open(cache, encoding="utf-8", mode="w") as f: + yaml.safe_dump(result, f) + except: + logger.info("Fetched plugin blacklist but couldn't write it to its cache file.") + except: + logger.info("Unable to fetch plugin blacklist from {}, proceeding without it.".format(url)) + return result + + try: + # first attempt to fetch from cache + cache_path = os.path.join(settings.getBaseFolder("data"), "plugin_blacklist.yaml") + ttl = settings.getInt(["server", "pluginBlacklist", "ttl"]) + blacklist = fetch_blacklist_from_cache(cache_path, ttl) + + if blacklist is None: + # no result from the cache, let's fetch it fresh + url = settings.get(["server", "pluginBlacklist", "url"]) + timeout = settings.getFloat(["server", "pluginBlacklist", "timeout"]) + blacklist = fetch_blacklist_from_url(url, timeout=timeout, cache=cache_path) + + if blacklist is None: + # still now result, so no blacklist + blacklist = [] + + if blacklist: + logger.info("Blacklist processing done, " + "adding {} blacklisted plugin versions: {}".format(len(blacklist), + format_blacklist(blacklist))) + else: + logger.info("Blacklist processing done") + + return blacklist + except: + logger.exception("Something went wrong while processing the plugin blacklist. Proceeding without it.") + + +def init_event_manager(settings): + from octoprint.events import eventManager + return eventManager() + + +def init_connectivity_checker(settings, event_manager): + from octoprint.events import Events + from octoprint.util import ConnectivityChecker + + # start regular check if we are connected to the internet + connectivityEnabled = settings.getBoolean(["server", "onlineCheck", "enabled"]) + connectivityInterval = settings.getInt(["server", "onlineCheck", "interval"]) + connectivityHost = settings.get(["server", "onlineCheck", "host"]) + connectivityPort = settings.getInt(["server", "onlineCheck", "port"]) + + def on_connectivity_change(old_value, new_value): + event_manager.fire(Events.CONNECTIVITY_CHANGED, payload=dict(old=old_value, new=new_value)) + + connectivityChecker = ConnectivityChecker(connectivityInterval, + connectivityHost, + port=connectivityPort, + enabled=connectivityEnabled, + on_change=on_connectivity_change) + + return connectivityChecker + + #~~ server main method def main(): diff --git a/src/octoprint/cli/__init__.py b/src/octoprint/cli/__init__.py index 9562b173..34ad3f30 100644 --- a/src/octoprint/cli/__init__.py +++ b/src/octoprint/cli/__init__.py @@ -106,9 +106,9 @@ def standard_options(hidden=False): factory("--config", "-c", "configfile", type=click.Path(), callback=set_ctx_obj_option, is_eager=True, expose_value=False, help="Specify the config file to use."), factory("--verbose", "-v", "verbosity", count=True, callback=set_ctx_obj_option, is_eager=True, expose_value=False, - help="Increase logging verbosity"), + help="Increase logging verbosity."), factory("--safe", "safe_mode", is_flag=True, callback=set_ctx_obj_option, is_eager=True, expose_value=False, - help="Enable safe mode; disables all third party plugins") + help="Enable safe mode; disables all third party plugins.") ] return bulk_options(options) @@ -123,6 +123,7 @@ legacy_options = bulk_options([ hidden_option("--daemon", type=click.Choice(["start", "stop", "restart"]), callback=set_ctx_obj_option), hidden_option("--pid", type=click.Path(), default="/tmp/octoprint.pid", callback=set_ctx_obj_option), hidden_option("--iknowwhatimdoing", "allow_root", is_flag=True, callback=set_ctx_obj_option), + hidden_option("--ignore-blacklist", "ignore_blacklist", is_flag=True, callback=set_ctx_obj_option) ]) """Legacy options available directly on the "octoprint" command in earlier versions. Kept available for reasons of backwards compatibility, but hidden from the diff --git a/src/octoprint/cli/plugins.py b/src/octoprint/cli/plugins.py index e650129b..facfc80c 100644 --- a/src/octoprint/cli/plugins.py +++ b/src/octoprint/cli/plugins.py @@ -43,6 +43,7 @@ class OctoPrintPluginCommands(click.MultiCommand): if self._initialized: return + click.echo("Initializing settings & plugin subsystem...") if ctx.obj is None: ctx.obj = OctoPrintContext() @@ -51,7 +52,8 @@ class OctoPrintPluginCommands(click.MultiCommand): from octoprint import init_settings, init_pluginsystem, FatalStartupError try: self.settings = init_settings(get_ctx_obj_option(ctx, "basedir", None), get_ctx_obj_option(ctx, "configfile", None)) - self.plugin_manager = init_pluginsystem(self.settings, safe_mode=get_ctx_obj_option(ctx, "safe_mode", False)) + self.plugin_manager = init_pluginsystem(self.settings, + safe_mode=get_ctx_obj_option(ctx, "safe_mode", False)) except FatalStartupError as e: click.echo(e.message, err=True) click.echo("There was a fatal error initializing the settings or the plugin system.", err=True) diff --git a/src/octoprint/cli/server.py b/src/octoprint/cli/server.py index 5789af64..3adea9f5 100644 --- a/src/octoprint/cli/server.py +++ b/src/octoprint/cli/server.py @@ -11,7 +11,8 @@ import sys from octoprint.cli import bulk_options, standard_options, set_ctx_obj_option, get_ctx_obj_option -def run_server(basedir, configfile, host, port, debug, allow_root, logging_config, verbosity, safe_mode, octoprint_daemon=None): +def run_server(basedir, configfile, host, port, debug, allow_root, logging_config, verbosity, safe_mode, + ignore_blacklist, octoprint_daemon=None): """Initializes the environment and starts up the server.""" from octoprint import init_platform, __display_version__, FatalStartupError @@ -19,7 +20,7 @@ def run_server(basedir, configfile, host, port, debug, allow_root, logging_confi def log_startup(recorder=None, safe_mode=None, **kwargs): from octoprint.logging import get_divider_line - logger = logging.getLogger("octoprint.server") + logger = logging.getLogger("octoprint.startup") logger.info(get_divider_line("*")) logger.info("Starting OctoPrint {}".format(__display_version__)) @@ -71,15 +72,17 @@ def run_server(basedir, configfile, host, port, debug, allow_root, logging_confi OctoPrintLogHandler.registerRolloverCallback(rollover_callback) try: - settings, _, safe_mode, plugin_manager = init_platform(basedir, - configfile, - logging_file=logging_config, - debug=debug, - verbosity=verbosity, - uncaught_logger=__name__, - safe_mode=safe_mode, - after_safe_mode=log_startup, - after_plugin_manager=log_register_rollover) + settings, _, safe_mode, event_manager, \ + connectivity_checker, plugin_manager = init_platform(basedir, + configfile, + logging_file=logging_config, + debug=debug, + verbosity=verbosity, + uncaught_logger=__name__, + safe_mode=safe_mode, + ignore_blacklist=ignore_blacklist, + after_safe_mode=log_startup, + after_plugin_manager=log_register_rollover) except FatalStartupError as e: click.echo(e.message, err=True) click.echo("There was a fatal error starting up OctoPrint.", err=True) @@ -87,6 +90,8 @@ def run_server(basedir, configfile, host, port, debug, allow_root, logging_confi from octoprint.server import Server octoprint_server = Server(settings=settings, plugin_manager=plugin_manager, + event_manager=event_manager, + connectivity_checker=connectivity_checker, host=host, port=port, debug=debug, @@ -107,7 +112,9 @@ server_options = bulk_options([ click.option("--iknowwhatimdoing", "allow_root", is_flag=True, callback=set_ctx_obj_option, help="Allow OctoPrint to run as user root."), click.option("--debug", is_flag=True, callback=set_ctx_obj_option, - help="Enable debug mode.") + help="Enable debug mode."), + click.option("--ignore-blacklist", "ignore_blacklist", is_flag=True, callback=set_ctx_obj_option, + help="Disable processing of the plugin blacklist.") ]) """Decorator to add the options shared among the server commands: ``--host``, ``--port``, ``--logging``, ``--iknowwhatimdoing`` and ``--debug``.""" @@ -126,7 +133,7 @@ def server_commands(): @server_commands.command(name="safemode") -@standard_options(hidden=True) +@standard_options() @click.pass_context def enable_safemode(ctx, **kwargs): """Sets the safe mode flag for the next start.""" @@ -147,8 +154,8 @@ def enable_safemode(ctx, **kwargs): @server_commands.command(name="serve") +@standard_options() @server_options -@standard_options(hidden=True) @click.pass_context def serve_command(ctx, **kwargs): """Starts the OctoPrint server.""" @@ -166,18 +173,20 @@ def serve_command(ctx, **kwargs): configfile = get_value("configfile") verbosity = get_value("verbosity") safe_mode = get_value("safe_mode") + ignore_blacklist = get_value("ignore_blacklist") run_server(basedir, configfile, host, port, debug, - allow_root, logging, verbosity, safe_mode) + allow_root, logging, verbosity, safe_mode, + ignore_blacklist) if sys.platform != "win32" and sys.platform != "darwin": # we do not support daemon mode under windows or macosx @server_commands.command(name="daemon") + @standard_options() @server_options @daemon_options - @standard_options(hidden=True) @click.argument("command", type=click.Choice(["start", "stop", "restart", "status"]), metavar="start|stop|restart|status") @click.pass_context @@ -185,7 +194,7 @@ if sys.platform != "win32" and sys.platform != "darwin": """ Starts, stops or restarts in daemon mode. - Please note that daemon mode is only supported under Linux right now. + Please note that daemon mode is not supported under Windows and MacOSX right now. """ def get_value(key): @@ -202,6 +211,7 @@ if sys.platform != "win32" and sys.platform != "darwin": configfile = get_value("configfile") verbosity = get_value("verbosity") safe_mode = get_value("safe_mode") + ignore_blacklist = get_value("ignore_blacklist") if pid is None: click.echo("No path to a pidfile set", @@ -210,7 +220,8 @@ if sys.platform != "win32" and sys.platform != "darwin": from octoprint.daemon import Daemon class OctoPrintDaemon(Daemon): - def __init__(self, pidfile, basedir, configfile, host, port, debug, allow_root, logging_config, verbosity, safe_mode): + def __init__(self, pidfile, basedir, configfile, host, port, debug, allow_root, logging_config, verbosity, + safe_mode, ignore_blacklist): Daemon.__init__(self, pidfile) self._basedir = basedir @@ -222,14 +233,15 @@ if sys.platform != "win32" and sys.platform != "darwin": self._logging_config = logging_config self._verbosity = verbosity self._safe_mode = safe_mode + self._ignore_blacklist = ignore_blacklist def run(self): run_server(self._basedir, self._configfile, self._host, self._port, self._debug, self._allow_root, self._logging_config, self._verbosity, self._safe_mode, - octoprint_daemon=self) + self._ignore_blacklist, octoprint_daemon=self) octoprint_daemon = OctoPrintDaemon(pid, basedir, configfile, host, port, debug, allow_root, logging, verbosity, - safe_mode) + safe_mode, ignore_blacklist) if command == "start": octoprint_daemon.start() diff --git a/src/octoprint/plugin/__init__.py b/src/octoprint/plugin/__init__.py index 2f77e2c4..114d2fe6 100644 --- a/src/octoprint/plugin/__init__.py +++ b/src/octoprint/plugin/__init__.py @@ -45,7 +45,8 @@ def _validate_plugin(phase, plugin_info): return True def plugin_manager(init=False, plugin_folders=None, plugin_types=None, plugin_entry_points=None, plugin_disabled_list=None, - plugin_restart_needing_hooks=None, plugin_obsolete_hooks=None, plugin_validators=None): + plugin_blacklist=None, plugin_restart_needing_hooks=None, plugin_obsolete_hooks=None, + plugin_validators=None): """ Factory method for initially constructing and consecutively retrieving the :class:`~octoprint.plugin.core.PluginManager` singleton. @@ -65,6 +66,7 @@ def plugin_manager(init=False, plugin_folders=None, plugin_types=None, plugin_en this defaults to the entry point ``octoprint.plugin``. plugin_disabled_list (list): A list of plugin identifiers that are currently disabled. If not provided this defaults to all plugins for which ``enabled`` is set to ``False`` in the settings. + plugin_blacklist (list): A list of plugin identifiers/identifier-version tuples that are currently blacklisted. plugin_restart_needing_hooks (list): A list of hook namespaces which cause a plugin to need a restart in order be enabled/disabled. Does not have to contain full hook identifiers, will be matched with startswith similar to logging handlers @@ -120,6 +122,7 @@ def plugin_manager(init=False, plugin_folders=None, plugin_types=None, plugin_en plugin_entry_points, logging_prefix="octoprint.plugins.", plugin_disabled_list=plugin_disabled_list, + plugin_blacklist=plugin_blacklist, plugin_restart_needing_hooks=plugin_restart_needing_hooks, plugin_obsolete_hooks=plugin_obsolete_hooks, plugin_validators=plugin_validators) diff --git a/src/octoprint/plugin/core.py b/src/octoprint/plugin/core.py index 76d092e7..d8a58e9e 100644 --- a/src/octoprint/plugin/core.py +++ b/src/octoprint/plugin/core.py @@ -36,6 +36,8 @@ import fnmatch import pkg_resources import pkginfo +from past.builtins import basestring + try: from os import scandir except ImportError: @@ -57,7 +59,7 @@ class PluginInfo(object): Arguments: key (str): Identifier of the plugin location (str): Installation folder of the plugin - instance (module): Plugin module instance + instance (module): Plugin module instance - this may be ``None`` if the plugin has been blacklisted! name (str): Human readable name of the plugin version (str): Version of the plugin description (str): Description of the plugin @@ -140,6 +142,7 @@ class PluginInfo(object): self.instance = instance self.origin = None self.enabled = True + self.blacklisted = False self.bundled = False self.loaded = False self.managable = True @@ -201,7 +204,7 @@ class PluginInfo(object): def long_str(self, show_bundled=False, bundled_strs=(" [B]", ""), show_location=False, location_str=" - {location}", - show_enabled=False, enabled_strs=("* ", " ")): + show_enabled=False, enabled_strs=("* ", " ", "X ")): """ Long string representation of the plugin's information. Will return a string of the format ````. @@ -209,8 +212,8 @@ class PluginInfo(object): The will be filled from ``enabled_str``, ``bundled_str`` and ``location_str`` as follows: ``enabled_str`` - a 2-tuple, the first entry being the string to insert when the plugin is enabled, the second - entry the string to insert when it is not. + a 3-tuple, the first entry being the string to insert when the plugin is enabled, the second + entry the string to insert when it is not, the third entry the string when it is blacklisted. ``bundled_str`` a 2-tuple, the first entry being the string to insert when the plugin is bundled, the second entry the string to insert when it is not. @@ -230,7 +233,7 @@ class PluginInfo(object): str: The long string representation of the plugin as described above """ if show_enabled: - ret = enabled_strs[0] if self.enabled else enabled_strs[1] + ret = enabled_strs[2] if self.blacklisted else (enabled_strs[0] if self.enabled else enabled_strs[1]) else: ret = "" @@ -445,7 +448,7 @@ class PluginInfo(object): return self._get_instance_attribute(self.__class__.attr_disable, default=lambda: True) def _get_instance_attribute(self, attr, default=None, defaults=None): - if not hasattr(self.instance, attr): + if self.instance is None or not hasattr(self.instance, attr): if defaults is not None: for value in defaults: if value is not None: @@ -463,8 +466,8 @@ class PluginManager(object): """ def __init__(self, plugin_folders, plugin_types, plugin_entry_points, logging_prefix=None, - plugin_disabled_list=None, plugin_restart_needing_hooks=None, plugin_obsolete_hooks=None, - plugin_validators=None): + plugin_disabled_list=None, plugin_blacklist=None, plugin_restart_needing_hooks=None, + plugin_obsolete_hooks=None, plugin_validators=None): self.logger = logging.getLogger(__name__) if logging_prefix is None: @@ -477,11 +480,14 @@ class PluginManager(object): plugin_entry_points = [] if plugin_disabled_list is None: plugin_disabled_list = [] + if plugin_blacklist is None: + plugin_blacklist = [] self.plugin_folders = plugin_folders self.plugin_types = plugin_types self.plugin_entry_points = plugin_entry_points self.plugin_disabled_list = plugin_disabled_list + self.plugin_blacklist = plugin_blacklist self.plugin_restart_needing_hooks = plugin_restart_needing_hooks self.plugin_obsolete_hooks = plugin_obsolete_hooks self.plugin_validators = plugin_validators @@ -543,7 +549,7 @@ class PluginManager(object): if existing is None: existing = dict(self.plugins) - result = dict() + result = OrderedDict() if self.plugin_folders: result.update(self._find_plugins_from_folders(self.plugin_folders, existing, ignored_uninstalled=ignore_uninstalled)) if self.plugin_entry_points: @@ -552,7 +558,7 @@ class PluginManager(object): return result def _find_plugins_from_folders(self, folders, existing, ignored_uninstalled=True): - result = dict() + result = OrderedDict() for folder in folders: flagged_readonly = False @@ -596,7 +602,7 @@ class PluginManager(object): return result def _find_plugins_from_entry_points(self, groups, existing, ignore_uninstalled=True): - result = dict() + result = OrderedDict() # let's make sure we have a current working set ... working_set = pkg_resources.WorkingSet() @@ -674,6 +680,12 @@ class PluginManager(object): self.logger.warn("Could not locate plugin {key}".format(key=key)) return None + if self._is_plugin_blacklisted(key) or (version is not None and self._is_plugin_version_blacklisted(key, version)): + plugin = PluginInfo(key, module[1], None, name=name, version=version, description=summary, author=author, url=url, license=license) + plugin.blacklisted = True + self.logger.warn("Plugin {} is blacklisted. Not importing it, only registering a dummy entry.".format(plugin)) + return plugin + plugin = self._import_plugin(key, *module, name=name, version=version, summary=summary, author=author, url=url, license=license) if plugin is None: return None @@ -696,6 +708,19 @@ class PluginManager(object): def _is_plugin_disabled(self, key): return key in self.plugin_disabled_list or key.endswith('disabled') + def _is_plugin_blacklisted(self, key): + return key in self.plugin_blacklist + + def _is_plugin_version_blacklisted(self, key, version): + def matches_plugin(entry): + if isinstance(entry, (tuple, list)) and len(entry) == 2: + entry_key, entry_version = entry + return entry_key == key and entry_version == version + return False + + return any(map(lambda entry: matches_plugin(entry), + self.plugin_blacklist)) + def reload_plugins(self, startup=False, initialize_implementations=True, force_reload=None): self.logger.info("Loading plugins from {folders} and installed plugin packages...".format( folders=", ".join(map(lambda x: x[0] if isinstance(x, tuple) else str(x), self.plugin_folders)) @@ -710,7 +735,8 @@ class PluginManager(object): # 1st pass: loading the plugins for name, plugin in plugins.items(): try: - self.load_plugin(name, plugin, startup=startup, initialize_implementation=initialize_implementations) + if not plugin.blacklisted: + self.load_plugin(name, plugin, startup=startup, initialize_implementation=initialize_implementations) except PluginNeedsRestart: pass except PluginLifecycleException as e: @@ -724,6 +750,9 @@ class PluginManager(object): for name, plugin in plugins.items(): try: if plugin.loaded and not self._is_plugin_disabled(name): + if plugin.blacklisted: + self.logger.warn("Plugin {} is blacklisted. Not enabling it.".format(plugin)) + continue self.enable_plugin(name, plugin=plugin, initialize_implementation=initialize_implementations, startup=startup) except PluginNeedsRestart: pass @@ -780,6 +809,11 @@ class PluginManager(object): plugin.loaded = True + # we might only now have a version, so check again if we are blacklisted + if not plugin.blacklisted and plugin.version and self._is_plugin_version_blacklisted(plugin.key, + plugin.version): + plugin.blacklisted = True + self.logger.debug("Loaded plugin {name}: {plugin}".format(**locals())) except PluginLifecycleException as e: raise e @@ -1146,7 +1180,7 @@ class PluginManager(object): def log_all_plugins(self, show_bundled=True, bundled_str=(" (bundled)", ""), show_location=True, - location_str=" = {location}", show_enabled=True, enabled_str=(" ", "!"), + location_str=" = {location}", show_enabled=True, enabled_str=(" ", "!", "#"), only_to_handler=None): all_plugins = self.enabled_plugins.values() + self.disabled_plugins.values() diff --git a/src/octoprint/plugins/corewizard/__init__.py b/src/octoprint/plugins/corewizard/__init__.py index bfeb93e7..7beb50a8 100644 --- a/src/octoprint/plugins/corewizard/__init__.py +++ b/src/octoprint/plugins/corewizard/__init__.py @@ -62,7 +62,8 @@ class CoreWizardPlugin(octoprint.plugin.AssetPlugin, def get_assets(self): if self.is_wizard_required(): return dict( - js=["js/corewizard.js"] + js=["js/corewizard.js"], + css=["css/corewizard.css"] ) else: return dict() @@ -91,7 +92,7 @@ class CoreWizardPlugin(octoprint.plugin.AssetPlugin, return result def get_wizard_version(self): - return 1 + return 2 #~~ ACL subwizard @@ -190,6 +191,23 @@ class CoreWizardPlugin(octoprint.plugin.AssetPlugin, def _get_onlinecheck_additional_wizard_template_data(self): return dict(mandatory=self._is_onlinecheck_wizard_required()) + #~~ Plugin blacklist subwizard + + def _is_pluginblacklist_wizard_firstrunonly(self): + return False + + def _is_pluginblacklist_wizard_required(self): + return self._settings.global_get(["server", "pluginBlacklist", "enabled"]) is None + + def _get_pluginblacklist_wizard_details(self): + return dict(required=self._is_pluginblacklist_wizard_required()) + + def _get_pluginblacklist_wizard_name(self): + return gettext("Plugin blacklist") + + def _get_pluginblacklist_additional_wizard_template_data(self): + return dict(mandatory=self._is_pluginblacklist_wizard_required()) + #~~ Printer profile subwizard def _is_printerprofile_wizard_firstrunonly(self): diff --git a/src/octoprint/plugins/corewizard/static/css/corewizard.css b/src/octoprint/plugins/corewizard/static/css/corewizard.css new file mode 100644 index 00000000..2f15349d --- /dev/null +++ b/src/octoprint/plugins/corewizard/static/css/corewizard.css @@ -0,0 +1,4 @@ +#wizard_plugin_corewizard_pluginblacklist p { + line-height: 1.5; + margin-bottom: 1.5em; +} diff --git a/src/octoprint/plugins/corewizard/static/js/corewizard.js b/src/octoprint/plugins/corewizard/static/js/corewizard.js index 2a09a07b..b96d92e2 100644 --- a/src/octoprint/plugins/corewizard/static/js/corewizard.js +++ b/src/octoprint/plugins/corewizard/static/js/corewizard.js @@ -207,6 +207,85 @@ $(function() { } + function CoreWizardPluginBlacklistViewModel(parameters) { + var self = this; + + self.settingsViewModel = parameters[0]; + + self.setup = ko.observable(false); + + self.decision = ko.observable(); + self.required = false; + self.active = false; + + self.enablePluginBlacklist = function() { + self.settingsViewModel.server_pluginBlacklist_enabled(true); + self.decision(true); + self._sendData(); + }; + + self.disablePluginBlacklist = function() { + self.settingsViewModel.server_pluginBlacklist_enabled(false); + self.decision(false); + self._sendData(); + }; + + self.onBeforeWizardTabChange = function(next, current) { + if (!self.required) return true; + + if (!current || !_.startsWith(current, "wizard_plugin_corewizard_pluginblacklist_") || self.setup()) { + return true; + } + + self._showDecisionNeededDialog(); + return false; + }; + + self.onBeforeWizardFinish = function() { + if (!self.required) return true; + + if (self.setup()) { + return true; + } + + self._showDecisionNeededDialog(); + return false; + }; + + self.onWizardPreventSettingsRefreshDialog = function() { + return self.active; + }; + + self.onWizardDetails = function(response) { + self.required = response && response.corewizard && response.corewizard.details && response.corewizard.details.pluginblacklist && response.corewizard.details.pluginblacklist.required; + }; + + self._showDecisionNeededDialog = function() { + showMessageDialog({ + title: gettext("Please set up the plugin blacklist processing"), + message: gettext("You haven't yet decided on whether to enable or disable the plugin blacklist processing. You need to either enable or disable it before continuing.") + }); + }; + + self._sendData = function() { + var data = { + server: { + pluginBlacklist: { + enabled: self.settingsViewModel.server_pluginBlacklist_enabled() + } + } + }; + + self.active = true; + self.settingsViewModel.saveData(data) + .done(function() { + self.setup(true); + self.active = false; + }); + }; + + } + function CoreWizardPrinterProfileViewModel(parameters) { var self = this; @@ -258,6 +337,10 @@ $(function() { CoreWizardOnlineCheckViewModel, ["settingsViewModel"], "#wizard_plugin_corewizard_onlinecheck" + ], [ + CoreWizardPluginBlacklistViewModel, + ["settingsViewModel"], + "#wizard_plugin_corewizard_pluginblacklist" ], [ CoreWizardPrinterProfileViewModel, ["printerProfilesViewModel"], diff --git a/src/octoprint/plugins/corewizard/templates/corewizard_pluginblacklist_wizard.jinja2 b/src/octoprint/plugins/corewizard/templates/corewizard_pluginblacklist_wizard.jinja2 new file mode 100644 index 00000000..f046ac2f --- /dev/null +++ b/src/octoprint/plugins/corewizard/templates/corewizard_pluginblacklist_wizard.jinja2 @@ -0,0 +1,26 @@ +

{{ _('Configure plugin blacklist processing') }}

+ +

{% trans %} + To protect against known severe issues with certain versions of third party plugins, OctoPrint supports the use + of a centralized plugin version blacklist to automatically disable such plugin versions before they can interfere with + normal operation, allowing you to uninstall or update them to a newer version. +{% endtrans %}

+ +

{% trans %} + Please decide whether to allow fetch and use of this centralized blacklist starting with the next server start. + You may also change your decision at any time through Settings > Server right from within OctoPrint. +{% endtrans %}

+ + + + diff --git a/src/octoprint/plugins/pluginmanager/__init__.py b/src/octoprint/plugins/pluginmanager/__init__.py index a6806ac7..1892a0e8 100644 --- a/src/octoprint/plugins/pluginmanager/__init__.py +++ b/src/octoprint/plugins/pluginmanager/__init__.py @@ -14,6 +14,7 @@ from octoprint.settings import valid_boolean_trues from octoprint.server.util.flask import restricted_access, with_revalidation_checking, check_etag from octoprint.server import admin_permission, VERSION from octoprint.util.pip import LocalPipCaller, UnknownPip +from octoprint.util.version import get_octoprint_version_string, get_octoprint_version, is_octoprint_compatible from flask import jsonify, make_response from flask.ext.babel import gettext @@ -31,6 +32,8 @@ import dateutil.parser import time import threading +_DATA_FORMAT_VERSION = "v2" + class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, octoprint.plugin.TemplatePlugin, octoprint.plugin.AssetPlugin, @@ -239,7 +242,7 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, plugins=self._repository_plugins ), os=self._get_os(), - octoprint=self._get_octoprint_version_string(), + octoprint=get_octoprint_version_string(), pip=dict( available=self._pip_caller.available, version=self._pip_caller.version_string, @@ -262,6 +265,7 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, hash.update(repr(self._notices)) hash.update(repr(safe_mode)) hash.update(repr(self._connectivity_checker.online)) + hash.update(repr(_DATA_FORMAT_VERSION)) return hash.hexdigest() def condition(): @@ -526,7 +530,8 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, if not needs_restart: try: - self._plugin_manager.disable_plugin(plugin.key, plugin=plugin) + if plugin.enabled: + self._plugin_manager.disable_plugin(plugin.key, plugin=plugin) except octoprint.plugin.core.PluginLifecycleException as e: self._logger.exception(u"Problem disabling plugin {name}".format(name=plugin.key)) result = dict(result=False, uninstalled=True, disabled=False, unloaded=False, reason=e.reason) @@ -534,7 +539,8 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, return jsonify(result) try: - self._plugin_manager.unload_plugin(plugin.key) + if plugin.loaded: + self._plugin_manager.unload_plugin(plugin.key) except octoprint.plugin.core.PluginLifecycleException as e: self._logger.exception(u"Problem unloading plugin {name}".format(name=plugin.key)) result = dict(result=False, uninstalled=True, disabled=True, unloaded=False, reason=e.reason) @@ -754,7 +760,7 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, return False current_os = self._get_os() - octoprint_version = self._get_octoprint_version(base=True) + octoprint_version = get_octoprint_version(base=True) def map_repository_entry(entry): result = copy.deepcopy(entry) @@ -769,7 +775,8 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, if "compatibility" in entry: if "octoprint" in entry["compatibility"] and entry["compatibility"]["octoprint"] is not None and isinstance(entry["compatibility"]["octoprint"], (list, tuple)) and len(entry["compatibility"]["octoprint"]): - result["is_compatible"]["octoprint"] = self._is_octoprint_compatible(octoprint_version, entry["compatibility"]["octoprint"]) + result["is_compatible"]["octoprint"] = is_octoprint_compatible(*entry["compatibility"]["octoprint"], + octoprint_version=octoprint_version) if "os" in entry["compatibility"] and entry["compatibility"]["os"] is not None and isinstance(entry["compatibility"]["os"], (list, tuple)) and len(entry["compatibility"]["os"]): result["is_compatible"]["os"] = self._is_os_compatible(current_os, entry["compatibility"]["os"]) @@ -847,26 +854,6 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, self._notices = notices return True - def _is_octoprint_compatible(self, octoprint_version, compatibility_entries): - """ - Tests if the current ``octoprint_version`` is compatible to any of the provided ``compatibility_entries``. - """ - - for octo_compat in compatibility_entries: - try: - if not any(octo_compat.startswith(c) for c in ("<", "<=", "!=", "==", ">=", ">", "~=", "===")): - octo_compat = ">={}".format(octo_compat) - - s = next(pkg_resources.parse_requirements("OctoPrint" + octo_compat)) - if octoprint_version in s: - break - except: - self._logger.exception("Something is wrong with this compatibility string for OctoPrint: {}".format(octo_compat)) - else: - return False - - return True - @staticmethod def _is_os_compatible(current_os, compatibility_entries): """ @@ -899,38 +886,6 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, else: return "unmapped" - def _get_octoprint_version_string(self): - return VERSION - - def _get_octoprint_version(self, base=False): - octoprint_version_string = self._get_octoprint_version_string() - - if "-" in octoprint_version_string: - octoprint_version_string = octoprint_version_string[:octoprint_version_string.find("-")] - - octoprint_version = pkg_resources.parse_version(octoprint_version_string) - - # A leading v is common in github release tags and old setuptools doesn't remove it. While OctoPrint's - # versions should never contains such a prefix, we'll make sure to have stuff behave the same - # regardless of setuptools version anyhow. - if octoprint_version and isinstance(octoprint_version, tuple) and octoprint_version[0].lower() == "*v": - octoprint_version = octoprint_version[1:] - - if base: - if isinstance(octoprint_version, tuple): - # old setuptools - base_version = [] - for part in octoprint_version: - if part.startswith("*"): - break - base_version.append(part) - base_version.append("*final") - octoprint_version = tuple(base_version) - else: - # new setuptools - octoprint_version = pkg_resources.parse_version(octoprint_version.base_version) - return octoprint_version - @property def _reconnect_hooks(self): reconnect_hooks = self.__class__.RECONNECT_HOOKS @@ -972,6 +927,7 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, bundled=plugin.bundled, managable=plugin.managable, enabled=plugin.enabled, + blacklisted=plugin.blacklisted, safe_mode_victim=getattr(plugin, "safe_mode_victim", False), safe_mode_enabled=getattr(plugin, "safe_mode_enabled", False), pending_enable=(not plugin.enabled and not getattr(plugin, "safe_mode_enabled", False) and plugin.key in self._pending_enable), @@ -990,13 +946,14 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, if key not in self._notices: return - octoprint_version = self._get_octoprint_version(base=True) + octoprint_version = get_octoprint_version(base=True) plugin_notifications = self._notices.get(key, []) def filter_relevant(notification): return "text" in notification and "date" in notification and \ ("versions" not in notification or plugin.version in notification["versions"]) and \ - ("octoversions" not in notification or self._is_octoprint_compatible(octoprint_version, notification["octoversions"])) + ("octoversions" not in notification or is_octoprint_compatible(*notification["octoversions"], + octoprint_version=octoprint_version)) def map_notification(notification): return self._to_external_notification(key, notification) diff --git a/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js b/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js index 4d42feac..8675adda 100644 --- a/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js +++ b/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js @@ -222,7 +222,9 @@ $(function() { self.enableToggle = function(data) { var command = self._getToggleCommand(data); - return self.enableManagement() && (command == "disable" || !data.safe_mode_victim || data.safe_mode_enabled) && data.key != 'pluginmanager'; + var not_safemode_victim = !data.safe_mode_victim || data.safe_mode_enabled; + var not_blacklisted = !data.blacklisted; + return self.enableManagement() && (command == "disable" || (not_safemode_victim && not_blacklisted)) && data.key != 'pluginmanager'; }; self.enableUninstall = function(data) { @@ -699,7 +701,7 @@ $(function() { return self.isCompatible(data) ? (self.installed(data) ? gettext("Reinstall") : gettext("Install")) : (data.disabled ? gettext("Disabled") : gettext("Incompatible")); }; - self._displayNotification = function(response, titleSuccess, textSuccess, textRestart, textReload, textReconnect, titleError, textError) { + self._displayNotification = function(response, action, titleSuccess, textSuccess, textRestart, textReload, textReconnect, titleError, textError) { var notification; var beforeClose = function(notification) { @@ -707,7 +709,17 @@ $(function() { }; if (response.result) { - if (response.needs_restart) { + if (action == "install" && response.plugin && response.plugin.blacklisted) { + notification = new PNotify({ + title: titleSuccess, + text: textSuccess, + type: "warning", + callbacks: { + before_close: beforeClose + }, + hide: false + }) + } else if (response.needs_restart) { var options = { title: titleSuccess, text: textRestart, @@ -859,7 +871,9 @@ $(function() { self.toggleButtonTitle = function(data) { var command = self._getToggleCommand(data); if (command == "enable") { - if (data.safe_mode_victim && !data.safe_mode_enabled) { + if (data.blacklisted) { + return gettext("Blacklisted"); + } else if (data.safe_mode_victim && !data.safe_mode_enabled) { return gettext("Disabled due to active safe mode"); } else { return gettext("Enable Plugin"); @@ -1121,6 +1135,17 @@ $(function() { textRestart = textSuccess; textReload = textSuccess; textReconnect = textSuccess; + } else if (data.plugin && data.plugin.blacklisted) { + if (data.was_reinstalled) { + titleSuccess = _.sprintf(gettext("Plugin \"%(name)s\" reinstalled"), {name: name}); + textSuccess = gettext("The plugin was reinstalled successfully, however it is blacklisted and therefore won't be loaded."); + } else { + titleSuccess = _.sprintf(gettext("Plugin \"%(name)s\" installed"), {name: name}); + textSuccess = gettext("The plugin was installed successfully, however it is blacklisted and therefore won't be loaded."); + } + textRestart = textSuccess; + textReload = textSuccess; + textReconnect = textSuccess; } else if (data.was_reinstalled) { titleSuccess = _.sprintf(gettext("Plugin \"%(name)s\" reinstalled"), {name: name}); textSuccess = gettext("The plugin was reinstalled successfully"); @@ -1233,7 +1258,7 @@ $(function() { return; } - self._displayNotification(data, titleSuccess, textSuccess, textRestart, textReload, textReconnect, titleError, textError); + self._displayNotification(data, action, titleSuccess, textSuccess, textRestart, textReload, textReconnect, titleError, textError); self.requestData(); } }; diff --git a/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 b/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 index 8829e656..4dc8b2ae 100644 --- a/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 +++ b/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 @@ -47,7 +47,7 @@ -
()
+
()
 
diff --git a/src/octoprint/server/__init__.py b/src/octoprint/server/__init__.py index 7363adfe..e792846a 100644 --- a/src/octoprint/server/__init__.py +++ b/src/octoprint/server/__init__.py @@ -146,9 +146,12 @@ def load_user(id): class Server(object): - def __init__(self, settings=None, plugin_manager=None, host="0.0.0.0", port=5000, debug=False, safe_mode=False, allow_root=False, octoprint_daemon=None): + def __init__(self, settings=None, plugin_manager=None, connectivity_checker=None, event_manager=None, + host="0.0.0.0", port=5000, debug=False, safe_mode=False, allow_root=False, octoprint_daemon=None): self._settings = settings self._plugin_manager = plugin_manager + self._connectivity_checker = connectivity_checker + self._event_manager = event_manager self._host = host self._port = port self._debug = debug @@ -233,7 +236,7 @@ class Server(object): pluginManager.reload_plugins(startup=True, initialize_implementations=False) printerProfileManager = PrinterProfileManager() - eventManager = events.eventManager() + eventManager = self._event_manager analysisQueue = octoprint.filemanager.analysis.AnalysisQueue() slicingManager = octoprint.slicing.SlicingManager(self._settings.getBaseFolder("slicingProfiles"), printerProfileManager) @@ -245,20 +248,7 @@ class Server(object): pluginLifecycleManager = LifecycleManager(pluginManager) preemptiveCache = PreemptiveCache(os.path.join(self._settings.getBaseFolder("data"), "preemptive_cache_config.yaml")) - # start regular check if we are connected to the internet - connectivityEnabled = self._settings.getBoolean(["server", "onlineCheck", "enabled"]) - connectivityInterval = self._settings.getInt(["server", "onlineCheck", "interval"]) - connectivityHost = self._settings.get(["server", "onlineCheck", "host"]) - connectivityPort = self._settings.getInt(["server", "onlineCheck", "port"]) - - def on_connectivity_change(old_value, new_value): - eventManager.fire(events.Events.CONNECTIVITY_CHANGED, payload=dict(old=old_value, new=new_value)) - - connectivityChecker = octoprint.util.ConnectivityChecker(connectivityInterval, - connectivityHost, - port=connectivityPort, - enabled=connectivityEnabled, - on_change=on_connectivity_change) + connectivityChecker = self._connectivity_checker def on_settings_update(*args, **kwargs): # make sure our connectivity checker runs with the latest settings diff --git a/src/octoprint/server/api/settings.py b/src/octoprint/server/api/settings.py index 5112eca3..4223416e 100644 --- a/src/octoprint/server/api/settings.py +++ b/src/octoprint/server/api/settings.py @@ -208,6 +208,11 @@ def getSettings(): "interval": int(s.getInt(["server", "onlineCheck", "interval"]) / 60), "host": s.get(["server", "onlineCheck", "host"]), "port": s.getInt(["server", "onlineCheck", "port"]) + }, + "pluginBlacklist": { + "enabled": s.getBoolean(["server", "pluginBlacklist", "enabled"]), + "url": s.get(["server", "pluginBlacklist", "url"]), + "ttl": int(s.getInt(["server", "pluginBlacklist", "ttl"]) / 60) } } } @@ -435,6 +440,15 @@ def _saveSettings(data): pass if "host" in data["server"]["onlineCheck"]: s.set(["server", "onlineCheck", "host"], data["server"]["onlineCheck"]["host"]) if "port" in data["server"]["onlineCheck"]: s.setInt(["server", "onlineCheck", "port"], data["server"]["onlineCheck"]["port"]) + if "pluginBlacklist" in data["server"]: + if "enabled" in data["server"]["pluginBlacklist"]: s.setBoolean(["server", "pluginBlacklist", "enabled"], data["server"]["pluginBlacklist"]["enabled"]) + if "url" in data["server"]["pluginBlacklist"]: s.set(["server", "pluginBlacklist", "url"], data["server"]["pluginBlacklist"]["url"]) + if "ttl" in data["server"]["pluginBlacklist"]: + try: + ttl = int(data["server"]["pluginBlacklist"]["ttl"]) + s.setInt(["server", "pluginBlacklist", "ttl"], ttl * 60) + except ValueError: + pass if "plugins" in data: for plugin in octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.SettingsPlugin): diff --git a/src/octoprint/settings.py b/src/octoprint/settings.py index d28d352b..7926af1b 100644 --- a/src/octoprint/settings.py +++ b/src/octoprint/settings.py @@ -154,6 +154,11 @@ default_settings = { "host": "8.8.8.8", "port": 53 }, + "pluginBlacklist": { + "enabled": None, + "url": "http://plugins.octoprint.org/blacklist.json", + "ttl": 15 * 60 # 15 min + }, "diskspace": { "warning": 500 * 1024 * 1024, # 500 MB "critical": 200 * 1024 * 1024, # 200 MB diff --git a/src/octoprint/static/js/app/viewmodels/settings.js b/src/octoprint/static/js/app/viewmodels/settings.js index d5607e71..c338aeda 100644 --- a/src/octoprint/static/js/app/viewmodels/settings.js +++ b/src/octoprint/static/js/app/viewmodels/settings.js @@ -218,6 +218,10 @@ $(function() { self.server_onlineCheck_host = ko.observable(); self.server_onlineCheck_port = ko.observable(); + self.server_pluginBlacklist_enabled = ko.observable(); + self.server_pluginBlacklist_url = ko.observable(); + self.server_pluginBlacklist_ttl = ko.observable(); + self.settings = undefined; self.lastReceivedSettings = undefined; diff --git a/src/octoprint/templates/dialogs/settings/server.jinja2 b/src/octoprint/templates/dialogs/settings/server.jinja2 index 8e3fd59e..b7f6c497 100644 --- a/src/octoprint/templates/dialogs/settings/server.jinja2 +++ b/src/octoprint/templates/dialogs/settings/server.jinja2 @@ -10,4 +10,8 @@ {% include "snippets/settings/server/serverOnlineCheckDescription.jinja2" %} {% include "snippets/settings/server/serverOnlineCheck.jinja2" %} +

{{ _('Plugin blacklist processing') }}

+ + {% include "snippets/settings/server/serverPluginBlacklistDescription.jinja2" %} + {% include "snippets/settings/server/serverPluginBlacklist.jinja2" %} diff --git a/src/octoprint/templates/snippets/settings/server/serverPluginBlacklist.jinja2 b/src/octoprint/templates/snippets/settings/server/serverPluginBlacklist.jinja2 new file mode 100644 index 00000000..02624d73 --- /dev/null +++ b/src/octoprint/templates/snippets/settings/server/serverPluginBlacklist.jinja2 @@ -0,0 +1,11 @@ +{% include "snippets/settings/server/serverPluginBlacklistEnabled.jinja2" %} + +
+
+ +
+ {% include "snippets/settings/server/serverPluginBlacklistUrl.jinja2" %} + {% include "snippets/settings/server/serverPluginBlacklistTtl.jinja2" %} +
+
+
diff --git a/src/octoprint/templates/snippets/settings/server/serverPluginBlacklistDescription.jinja2 b/src/octoprint/templates/snippets/settings/server/serverPluginBlacklistDescription.jinja2 new file mode 100644 index 00000000..d4b12013 --- /dev/null +++ b/src/octoprint/templates/snippets/settings/server/serverPluginBlacklistDescription.jinja2 @@ -0,0 +1,5 @@ +

{% trans %} + To protect against known severe issues with certain versions of third party plugins, OctoPrint supports + the use of a centralized plugin version blacklist to automatically disable such plugins before they can interfere + with normal operation. +{% endtrans %}

diff --git a/src/octoprint/templates/snippets/settings/server/serverPluginBlacklistEnabled.jinja2 b/src/octoprint/templates/snippets/settings/server/serverPluginBlacklistEnabled.jinja2 new file mode 100644 index 00000000..91576aee --- /dev/null +++ b/src/octoprint/templates/snippets/settings/server/serverPluginBlacklistEnabled.jinja2 @@ -0,0 +1,8 @@ +
+
+ +
+
diff --git a/src/octoprint/templates/snippets/settings/server/serverPluginBlacklistTtl.jinja2 b/src/octoprint/templates/snippets/settings/server/serverPluginBlacklistTtl.jinja2 new file mode 100644 index 00000000..a586bac8 --- /dev/null +++ b/src/octoprint/templates/snippets/settings/server/serverPluginBlacklistTtl.jinja2 @@ -0,0 +1,9 @@ +
+ +
+
+ + min +
+
+
diff --git a/src/octoprint/templates/snippets/settings/server/serverPluginBlacklistUrl.jinja2 b/src/octoprint/templates/snippets/settings/server/serverPluginBlacklistUrl.jinja2 new file mode 100644 index 00000000..781b1846 --- /dev/null +++ b/src/octoprint/templates/snippets/settings/server/serverPluginBlacklistUrl.jinja2 @@ -0,0 +1,6 @@ +
+ +
+ +
+
diff --git a/src/octoprint/util/version.py b/src/octoprint/util/version.py new file mode 100644 index 00000000..0b153a3a --- /dev/null +++ b/src/octoprint/util/version.py @@ -0,0 +1,88 @@ +# coding=utf-8 +""" +This module provides a bunch of utility methods and helpers for version handling. +""" +from __future__ import absolute_import, division, print_function + +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' + +import pkg_resources +import logging + +from octoprint._version import get_versions + + +_VERSION = get_versions()["version"] + + +def get_octoprint_version_string(): + return _VERSION + + +def get_octoprint_version(base=False): + octoprint_version_string = get_octoprint_version_string() + + if "-" in octoprint_version_string: + octoprint_version_string = octoprint_version_string[:octoprint_version_string.find("-")] + + octoprint_version = pkg_resources.parse_version(octoprint_version_string) + + # A leading v is common in github release tags and old setuptools doesn't remove it. While OctoPrint's + # versions should never contain such a prefix, we'll make sure to have stuff behave the same + # regardless of setuptools version anyhow. + if octoprint_version and isinstance(octoprint_version, tuple) and octoprint_version[0].lower() == "*v": + octoprint_version = octoprint_version[1:] + + if base: + if isinstance(octoprint_version, tuple): + # old setuptools + base_version = [] + for part in octoprint_version: + if part.startswith("*"): + break + base_version.append(part) + base_version.append("*final") + octoprint_version = tuple(base_version) + else: + # new setuptools + octoprint_version = pkg_resources.parse_version(octoprint_version.base_version) + return octoprint_version + + +def is_octoprint_compatible(*compatibility_entries, **kwargs): + """ + Tests if the current ``octoprint_version`` is compatible to any of the provided ``compatibility_entries``. + + Arguments: + compatibility_entries (str): compatibility string(s) to test against, result will be `True` if any match + is found + octoprint_version (tuple or SetuptoolsVersion): optional OctoPrint version to match against, if not current + base version will be determined via :func:`get_octoprint_version`. + + Returns: + (bool) ``True`` if any of the provided compatibility entries matches or there are no entries, else ``False`` + """ + + logger = logging.getLogger(__name__) + + if not compatibility_entries: + return True + + octoprint_version = kwargs.get("octoprint_version") + if octoprint_version is None: + octoprint_version = get_octoprint_version(base=True) + + for octo_compat in compatibility_entries: + try: + if not any(octo_compat.startswith(c) for c in ("<", "<=", "!=", "==", ">=", ">", "~=", "===")): + octo_compat = ">={}".format(octo_compat) + + s = next(pkg_resources.parse_requirements("OctoPrint" + octo_compat)) + if octoprint_version in s: + break + except: + logger.exception("Something is wrong with this compatibility string for OctoPrint: {}".format(octo_compat)) + else: + return False + + return True