示例#1
0
    def _start_db_cleaner(self):
        def _cleaner_task(db):
            oldest = self._cfg.getInt(self._cfg.DEFAULT_DB_MAX_DAYS, 1)
            db.purge_oldest(oldest)

        interval = self._cfg.getInt(self._cfg.DEFAULT_DB_PURGE_INTERVAL, 5)
        self._cleaner = CleanerTask(interval, _cleaner_task)
        self._cleaner.start()
示例#2
0
class UIService(ui_pb2_grpc.UIServicer, QtWidgets.QGraphicsObject):
    _new_remote_trigger = QtCore.pyqtSignal(str, ui_pb2.PingRequest)
    _node_actions_trigger = QtCore.pyqtSignal(dict)
    _update_stats_trigger = QtCore.pyqtSignal(str, str, ui_pb2.PingRequest)
    _version_warning_trigger = QtCore.pyqtSignal(str, str)
    _status_change_trigger = QtCore.pyqtSignal(bool)
    _notification_callback = QtCore.pyqtSignal(ui_pb2.NotificationReply)
    _show_message_trigger = QtCore.pyqtSignal(str, str, int)

    # .desktop filename located under /usr/share/applications/
    DESKTOP_FILENAME = "opensnitch_ui.desktop"

    def __init__(self, app, on_exit):
        super(UIService, self).__init__()

        self.MENU_ENTRY_STATS = QtCore.QCoreApplication.translate(
            "contextual_menu", "Statistics")
        self.MENU_ENTRY_FW_ENABLE = QtCore.QCoreApplication.translate(
            "contextual_menu", "Enable")
        self.MENU_ENTRY_FW_DISABLE = QtCore.QCoreApplication.translate(
            "contextual_menu", "Disable")
        self.MENU_ENTRY_HELP = QtCore.QCoreApplication.translate(
            "contextual_menu", "Help")
        self.MENU_ENTRY_CLOSE = QtCore.QCoreApplication.translate(
            "contextual_menu", "Close")

        # set of actions that must be performed on the main thread
        self.NODE_ADD = 0
        self.NODE_UPDATE = 1
        self.NODE_DELETE = 2
        self.ADD_RULE = 3
        self.DELETE_RULE = 4

        self._cfg = Config.init()
        self._db = Database.instance()
        db_file = self._cfg.getSettings(self._cfg.DEFAULT_DB_FILE_KEY)
        db_status, db_error = self._db.initialize(dbtype=self._cfg.getInt(
            self._cfg.DEFAULT_DB_TYPE_KEY),
                                                  dbfile=db_file)
        if db_status is False:
            Message.ok(
                QtCore.QCoreApplication.translate("preferences", "Warning"),
                QtCore.QCoreApplication.translate(
                    "preferences",
                    "The DB is corrupted and it's not safe to continue.<br>\
                                                  Remove, backup or recover the file before continuing.<br><br>\
                                                  Corrupted database file: {0}"
                    .format(db_file)), QtWidgets.QMessageBox.Warning)
            sys.exit(-1)

        self._db_sqlite = self._db.get_db()
        self._last_ping = None
        self._version_warning_shown = False
        self._asking = False
        self._connected = False
        self._fw_enabled = False
        self._path = os.path.abspath(os.path.dirname(__file__))
        self._app = app
        self._on_exit = on_exit
        self._exit = False
        self._msg = QtWidgets.QMessageBox()
        self._remote_lock = Lock()
        self._remote_stats = {}

        self._desktop_notifications = DesktopNotifications()
        self._setup_interfaces()
        self._setup_icons()
        self._prompt_dialog = PromptDialog(appicon=self.white_icon)
        self._stats_dialog = StatsDialog(dbname="general",
                                         db=self._db,
                                         appicon=self.white_icon)
        self._setup_tray()
        self._setup_slots()

        self._nodes = Nodes.instance()

        self._last_stats = {}
        self._last_items = {
            'hosts': {},
            'procs': {},
            'addrs': {},
            'ports': {},
            'users': {}
        }

        self._show_gui_if_tray_not_available()

        self._cleaner = None
        if self._cfg.getBool(Config.DEFAULT_DB_PURGE_OLDEST):
            self._start_db_cleaner()

    # https://gist.github.com/pklaus/289646
    def _setup_interfaces(self):
        namestr, outbytes = Utils.get_interfaces()
        self._interfaces = {}
        for i in range(0, outbytes, 40):
            name = namestr[i:i + 16].split(b'\0', 1)[0]
            addr = namestr[i + 20:i + 24]
            self._interfaces[name] = "%d.%d.%d.%d" % (int(addr[0]), int(
                addr[1]), int(addr[2]), int(addr[3]))

    def _setup_slots(self):
        # https://stackoverflow.com/questions/40288921/pyqt-after-messagebox-application-quits-why
        self._app.setQuitOnLastWindowClosed(False)
        self._version_warning_trigger.connect(self._on_diff_versions)
        self._new_remote_trigger.connect(self._on_new_remote)
        self._node_actions_trigger.connect(self._on_node_actions)
        self._update_stats_trigger.connect(self._on_update_stats)
        self._status_change_trigger.connect(self._on_status_changed)
        self._stats_dialog._shown_trigger.connect(self._on_stats_dialog_shown)
        self._stats_dialog._status_changed_trigger.connect(
            self._on_stats_status_changed)
        self._stats_dialog.settings_saved.connect(self._on_settings_saved)
        self._show_message_trigger.connect(self._show_systray_message)

    def _setup_icons(self):
        self.off_image = QtGui.QPixmap(
            os.path.join(self._path, "res/icon-off.png"))
        self.off_icon = QtGui.QIcon()
        self.off_icon.addPixmap(self.off_image, QtGui.QIcon.Normal,
                                QtGui.QIcon.Off)
        self.white_image = QtGui.QPixmap(
            os.path.join(self._path, "res/icon-white.svg"))
        self.white_icon = QtGui.QIcon()
        self.white_icon.addPixmap(self.white_image, QtGui.QIcon.Normal,
                                  QtGui.QIcon.Off)
        self.red_image = QtGui.QPixmap(
            os.path.join(self._path, "res/icon-red.png"))
        self.red_icon = QtGui.QIcon()
        self.red_icon.addPixmap(self.red_image, QtGui.QIcon.Normal,
                                QtGui.QIcon.Off)
        self.pause_image = QtGui.QPixmap(
            os.path.join(self._path, "res/icon-pause.png"))
        self.pause_icon = QtGui.QIcon()
        self.pause_icon.addPixmap(self.pause_image, QtGui.QIcon.Normal,
                                  QtGui.QIcon.Off)
        self.alert_image = QtGui.QPixmap(
            os.path.join(self._path, "res/icon-alert.png"))
        self.alert_icon = QtGui.QIcon()
        self.alert_icon.addPixmap(self.alert_image, QtGui.QIcon.Normal,
                                  QtGui.QIcon.Off)

        self._app.setWindowIcon(self.white_icon)
        # NOTE: only available since pyqt 5.7
        if hasattr(self._app, "setDesktopFileName"):
            self._app.setDesktopFileName(self.DESKTOP_FILENAME)

    def _setup_tray(self):
        self._tray = QtWidgets.QSystemTrayIcon(self.off_icon)
        self._tray.show()

        self._menu = QtWidgets.QMenu()
        self._tray.setContextMenu(self._menu)
        self._tray.activated.connect(self._on_tray_icon_activated)

        self._menu.addAction(self.MENU_ENTRY_STATS).triggered.connect(
            self._show_stats_dialog)
        self._menu_enable_fw = self._menu.addAction(self.MENU_ENTRY_FW_DISABLE)
        self._menu_enable_fw.setEnabled(False)
        self._menu_enable_fw.triggered.connect(
            self._on_enable_interception_clicked)
        self._menu.addAction(self.MENU_ENTRY_HELP).triggered.connect(
            lambda: QtGui.QDesktopServices.openUrl(
                QtCore.QUrl(Config.HELP_CONFIG_URL)))
        self._menu.addAction(self.MENU_ENTRY_CLOSE).triggered.connect(
            self._on_close)

    def _show_gui_if_tray_not_available(self):
        """If the system tray is not available or ready, show the GUI after
        10s. This delay helps to skip showing up the GUI when DEs' autologin is on.
        """
        tray = self._tray
        gui = self._stats_dialog

        def __show_gui():
            if not tray.isSystemTrayAvailable():
                gui.show()

        QtCore.QTimer.singleShot(10000, __show_gui)

    def _on_tray_icon_activated(self, reason):
        if reason == QtWidgets.QSystemTrayIcon.Trigger or reason == QtWidgets.QSystemTrayIcon.MiddleClick:
            if self._stats_dialog.isVisible(
            ) and not self._stats_dialog.isMinimized():
                self._stats_dialog.hide()
            elif self._stats_dialog.isVisible(
            ) and self._stats_dialog.isMinimized(
            ) and not self._stats_dialog.isMaximized():
                self._stats_dialog.hide()
                self._stats_dialog.showNormal()
            elif self._stats_dialog.isVisible(
            ) and self._stats_dialog.isMinimized(
            ) and self._stats_dialog.isMaximized():
                self._stats_dialog.hide()
                self._stats_dialog.showMaximized()
            else:
                self._stats_dialog.show()

    def _on_close(self):
        self._exit = True
        self._tray.setIcon(self.off_icon)
        self._app.processEvents()
        self._nodes.stop_notifications()
        self._nodes.update_all(Nodes.OFFLINE)
        self._db.vacuum()
        self._db.optimize()
        self._db.close()
        self._stop_db_cleaner()
        self._on_exit()

    def _show_stats_dialog(self):
        if self._connected and self._fw_enabled:
            self._tray.setIcon(self.white_icon)
        self._stats_dialog.show()

    @QtCore.pyqtSlot(bool)
    def _on_stats_status_changed(self, enabled):
        self._update_fw_status(enabled)

    @QtCore.pyqtSlot(bool)
    def _on_status_changed(self, enabled):
        self._set_daemon_connected(enabled)

    @QtCore.pyqtSlot(str, str)
    def _on_diff_versions(self, daemon_ver, ui_ver):
        if self._version_warning_shown == False:
            self._msg.setIcon(QtWidgets.QMessageBox.Warning)
            self._msg.setWindowTitle("OpenSnitch version mismatch!")
            self._msg.setText(("You are running version <b>%s</b> of the daemon, while the UI is at version " + \
                              "<b>%s</b>, they might not be fully compatible.") % (daemon_ver, ui_ver))
            self._msg.setStandardButtons(QtWidgets.QMessageBox.Ok)
            self._msg.show()
            self._version_warning_shown = True

    @QtCore.pyqtSlot(str, str, ui_pb2.PingRequest)
    def _on_update_stats(self, proto, addr, request):
        main_need_refresh, details_need_refresh = self._populate_stats(
            self._db, proto, addr, request.stats)
        is_local_request = self._is_local_request(proto, addr)
        self._stats_dialog.update(is_local_request, request.stats,
                                  main_need_refresh or details_need_refresh)

    @QtCore.pyqtSlot(str, ui_pb2.PingRequest)
    def _on_new_remote(self, addr, request):
        self._remote_stats[addr] = {
            'last_ping': datetime.now(),
            'dialog': StatsDialog(address=addr, dbname=addr, db=self._db)
        }
        self._remote_stats[addr]['dialog'].daemon_connected = True
        self._remote_stats[addr]['dialog'].update(addr, request.stats)
        self._remote_stats[addr]['dialog'].show()

    @QtCore.pyqtSlot()
    def _on_stats_dialog_shown(self):
        if self._connected:
            if self._fw_enabled:
                self._tray.setIcon(self.white_icon)
            else:
                self._tray.setIcon(self.pause_icon)
        else:
            self._tray.setIcon(self.off_icon)

    @QtCore.pyqtSlot(ui_pb2.NotificationReply)
    def _on_notification_reply(self, reply):
        if reply.code == ui_pb2.ERROR:
            self._tray.showMessage("Error", reply.data,
                                   QtWidgets.QSystemTrayIcon.Information, 5000)

    def _on_remote_stats_menu(self, address):
        self._remote_stats[address]['dialog'].show()

    @QtCore.pyqtSlot(str, str, int)
    def _show_systray_message(self, title, body, icon):
        if self._desktop_notifications.are_enabled():
            timeout = self._cfg.getInt(Config.DEFAULT_TIMEOUT_KEY, 15)

            if self._desktop_notifications.is_available() and self._cfg.getInt(
                    Config.NOTIFICATIONS_TYPE,
                    1) == Config.NOTIFICATION_TYPE_SYSTEM:
                self._desktop_notifications.show(
                    title, body, os.path.join(self._path,
                                              "res/icon-white.svg"))
            else:
                self._tray.showMessage(title, body, icon, timeout * 1000)

        if icon == QtWidgets.QSystemTrayIcon.NoIcon:
            self._tray.setIcon(self.alert_icon)

    def _on_enable_interception_clicked(self):
        self._enable_interception(self._fw_enabled)

    @QtCore.pyqtSlot()
    def _on_settings_saved(self):
        if self._cfg.getBool(Config.DEFAULT_DB_PURGE_OLDEST):
            if self._cleaner != None:
                self._stop_db_cleaner()
            self._start_db_cleaner()
        elif self._cfg.getBool(Config.DEFAULT_DB_PURGE_OLDEST
                               ) == False and self._cleaner != None:
            self._stop_db_cleaner()

    def _stop_db_cleaner(self):
        if self._cleaner != None:
            self._cleaner.stop()
            self._cleaner = None

    def _start_db_cleaner(self):
        def _cleaner_task(db):
            oldest = self._cfg.getInt(self._cfg.DEFAULT_DB_MAX_DAYS, 1)
            db.purge_oldest(oldest)

        interval = self._cfg.getInt(self._cfg.DEFAULT_DB_PURGE_INTERVAL, 5)
        self._cleaner = CleanerTask(interval, _cleaner_task)
        self._cleaner.start()

    def _update_fw_status(self, enabled):
        """_update_fw_status updates the status of the menu entry
        to disable or enable the firewall of the daemon.
        """
        self._fw_enabled = enabled
        if self._connected == False:
            return

        self._stats_dialog.update_interception_status(enabled)
        if enabled:
            self._tray.setIcon(self.white_icon)
            self._menu_enable_fw.setText(self.MENU_ENTRY_FW_DISABLE)
        else:
            self._tray.setIcon(self.pause_icon)
            self._menu_enable_fw.setText(self.MENU_ENTRY_FW_ENABLE)

    def _set_daemon_connected(self, connected):
        """_set_daemon_connected only updates the connection status of the daemon(s),
        regardless if the fw is enabled or not.
        There're 3 states:
            - daemon connected
            - daemon not connected
            - daemon connected and firewall enabled/disabled
        """
        self._stats_dialog.daemon_connected = connected
        self._connected = connected

        # if there're more than 1 node, override connection status
        if self._nodes.count() >= 1:
            self._connected = True
            self._stats_dialog.daemon_connected = True

        if self._nodes.count() == 1:
            self._menu_enable_fw.setEnabled(True)

        if self._nodes.count() == 0 or self._nodes.count() > 1:
            self._menu_enable_fw.setEnabled(False)

        self._stats_dialog.update_status()

        if self._connected:
            self._tray.setIcon(self.white_icon)
        else:
            self._fw_enabled = False
            self._tray.setIcon(self.off_icon)

    def _enable_interception(self, enable):
        if self._connected == False:
            return
        if self._nodes.count() == 0:
            self._tray.showMessage("No nodes connected", "",
                                   QtWidgets.QSystemTrayIcon.Information, 5000)
            return
        if self._nodes.count() > 1:
            print("enable interception for all nodes not supported yet")
            return

        if enable:
            nid, noti = self._nodes.stop_interception(
                _callback=self._notification_callback)
        else:
            nid, noti = self._nodes.start_interception(
                _callback=self._notification_callback)

        self._fw_enabled = not enable

        self._stats_dialog._status_changed_trigger.emit(not enable)

    def _is_local_request(self, proto, addr):
        if proto == "unix":
            return True

        elif proto == "ipv4" or proto == "ipv6":
            for name, ip in self._interfaces.items():
                if addr == ip:
                    return True

        return False

    def _get_peer(self, peer):
        """
        server          -> client
        127.0.0.1:50051 -> ipv4:127.0.0.1:52032
        [::]:50051      -> ipv6:[::1]:59680
        0.0.0.0:50051   -> ipv6:[::1]:59654
        """
        return self._nodes.get_addr(peer)

    def _delete_node(self, peer):
        try:
            proto, addr = self._get_peer(peer)
            if addr in self._last_stats:
                del self._last_stats[addr]
            for table in self._last_items:
                if addr in self._last_items[table]:
                    del self._last_items[table][addr]

            self._nodes.update(proto, addr, Nodes.OFFLINE)
            self._nodes.delete(peer)
            self._stats_dialog.update(True, None, True)
        except Exception as e:
            print("_delete_node() exception:", e)

    def _populate_stats(self, db, proto, addr, stats):
        main_need_refresh = False
        details_need_refresh = False
        try:
            if db == None:
                print("populate_stats() db None")
                return main_need_refresh, details_need_refresh

            _node = self._nodes.get_node(proto + ":" + addr)
            if _node == None:
                return main_need_refresh, details_need_refresh

            # TODO: move to nodes.add_node()
            version = _node['data'].version if _node != None else ""
            hostname = _node['data'].name if _node != None else ""
            db.insert("nodes",
                    "(addr, status, hostname, daemon_version, daemon_uptime, " \
                            "daemon_rules, cons, cons_dropped, version, last_connection)",
                            (addr, Nodes.ONLINE, hostname, stats.daemon_version, str(timedelta(seconds=stats.uptime)),
                            stats.rules, stats.connections, stats.dropped,
                            version, datetime.now().strftime("%Y-%m-%d %H:%M:%S")))

            if addr not in self._last_stats:
                self._last_stats[addr] = []

            db.transaction()
            for event in stats.events:
                if event.unixnano in self._last_stats[addr]:
                    continue
                main_need_refresh = True
                db.insert(
                    "connections",
                    "(time, node, action, protocol, src_ip, src_port, dst_ip, dst_host, dst_port, uid, pid, process, process_args, process_cwd, rule)",
                    (str(datetime.fromtimestamp(event.unixnano / 1000000000)),
                     "%s:%s" % (proto, addr), event.rule.action,
                     event.connection.protocol, event.connection.src_ip,
                     str(event.connection.src_port), event.connection.dst_ip,
                     event.connection.dst_host, str(event.connection.dst_port),
                     str(event.connection.user_id),
                     str(event.connection.process_id),
                     event.connection.process_path, " ".join(
                         event.connection.process_args),
                     event.connection.process_cwd, event.rule.name),
                    action_on_conflict="IGNORE")
                self._nodes.update_rule_time(
                    str(datetime.fromtimestamp(event.unixnano / 1000000000)),
                    event.rule.name, "%s:%s" % (proto, addr))
            db.commit()

            details_need_refresh = self._populate_stats_details(
                db, addr, stats)
            self._last_stats[addr] = []
            for event in stats.events:
                self._last_stats[addr].append(event.unixnano)
        except Exception as e:
            print("_populate_stats() exception: ", e)

        return main_need_refresh, details_need_refresh

    def _populate_stats_details(self, db, addr, stats):
        need_refresh = False
        changed = self._populate_stats_events(db, addr, stats, "hosts",
                                              ("what", "hits"), (1, 2),
                                              stats.by_host.items())
        if changed: need_refresh = True
        changed = self._populate_stats_events(db, addr, stats, "procs",
                                              ("what", "hits"), (1, 2),
                                              stats.by_executable.items())
        if changed: need_refresh = True
        changed = self._populate_stats_events(db, addr, stats, "addrs",
                                              ("what", "hits"), (1, 2),
                                              stats.by_address.items())
        if changed: need_refresh = True
        changed = self._populate_stats_events(db, addr, stats, "ports",
                                              ("what", "hits"), (1, 2),
                                              stats.by_port.items())
        if changed: need_refresh = True
        changed = self._populate_stats_events(db, addr, stats, "users",
                                              ("what", "hits"), (1, 2),
                                              stats.by_uid.items())
        if changed: need_refresh = True

        return need_refresh

    def _populate_stats_events(self, db, addr, stats, table, colnames, cols,
                               items):
        fields = []
        values = []
        need_refresh = False
        try:
            if addr not in self._last_items[table].keys():
                self._last_items[table][addr] = {}
            if items == self._last_items[table][addr]:
                return need_refresh

            for row, event in enumerate(items):
                if event in self._last_items[table][addr]:
                    continue
                need_refresh = True
                what, hits = event
                # FIXME: this is suboptimal
                # BUG: there can be users with same id on different machines but with different names
                if table == "users":
                    what = Utils.get_user_id(what)
                fields.append(what)
                values.append(int(hits))
            # FIXME: default action on conflict is to replace. If there're multiple nodes connected,
            # stats are painted once per node on each update.
            if need_refresh:
                db.insert_batch(table, colnames, cols, fields, values)

            self._last_items[table][addr] = items
        except Exception as e:
            print("details exception: ", e)

        return need_refresh

    def _overwrite_nodes_config(self, node_config):
        _default_action = self._cfg.getInt(self._cfg.DEFAULT_ACTION_KEY)
        temp_cfg = json.loads(node_config)
        try:
            if _default_action == Config.ACTION_DENY_IDX:
                temp_cfg['DefaultAction'] = Config.ACTION_DENY
            else:
                temp_cfg['DefaultAction'] = Config.ACTION_ALLOW

            node_config = json.dumps(temp_cfg)
        except Exception as e:
            print("error parsing node's configuration:", e)

        return node_config

    @QtCore.pyqtSlot(dict)
    def _on_node_actions(self, kwargs):
        if kwargs['action'] == self.NODE_ADD:
            n, addr = self._nodes.add(kwargs['peer'], kwargs['node_config'])
            if n != None:
                self._nodes.add_fw_rules(
                    addr,
                    FwRules.to_dict(
                        kwargs['node_config'].systemFirewall.SystemRules))
                self._status_change_trigger.emit(True)
                # if there're more than one node, we can't update the status
                # based on the fw status, only if the daemon is running or not
                if self._nodes.count() <= 1:
                    self._update_fw_status(
                        kwargs['node_config'].isFirewallRunning)
                else:
                    self._update_fw_status(True)
        elif kwargs['action'] == self.ADD_RULE:
            rule = kwargs['rule']
            proto, addr = self._get_peer(kwargs['peer'])
            self._nodes.add_rule(
                (datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
                "{0}:{1}".format(proto, addr), rule.name, rule.description,
                str(rule.enabled), str(rule.precedence), str(rule.nolog),
                rule.action, rule.duration, rule.operator.type,
                str(rule.operator.sensitive), rule.operator.operand,
                rule.operator.data)
        elif kwargs['action'] == self.DELETE_RULE:
            self._db.delete_rule(kwargs['name'], kwargs['addr'])

        elif kwargs['action'] == self.NODE_DELETE:
            self._delete_node(kwargs['peer'])

    def Ping(self, request, context):
        try:
            self._last_ping = datetime.now()
            if Utils.check_versions(request.stats.daemon_version):
                self._version_warning_trigger.emit(
                    request.stats.daemon_version, version)

            proto, addr = self._get_peer(context.peer())
            # do not update db here, do it on the main thread
            self._update_stats_trigger.emit(proto, addr, request)
            #else:
            #    with self._remote_lock:
            #        # XXX: disable this option for now
            #        # opening several dialogs only updates one of them.
            #        if addr not in self._remote_stats:
            #            self._new_remote_trigger.emit(addr, request)
            #        else:
            #            self._populate_stats(self._remote_stats[addr]['dialog'].get_db(), proto, addr, request.stats)
            #            self._remote_stats[addr]['dialog'].update(addr, request.stats)

        except Exception as e:
            print("Ping exception: ", e)

        return ui_pb2.PingReply(id=request.id)

    def AskRule(self, request, context):
        #def callback(ntf, action, connection):
        # TODO

        #if self._desktop_notifications.support_actions():
        #    self._desktop_notifications.ask(request, callback)

        # TODO: allow connections originated from ourselves: os.getpid() == request.pid)
        self._asking = True
        proto, addr = self._get_peer(context.peer())
        rule, timeout_triggered = self._prompt_dialog.promptUser(
            request, self._is_local_request(proto, addr), context.peer())
        self._last_ping = datetime.now()
        self._asking = False
        if rule == None:
            return None

        if timeout_triggered:
            _title = request.process_path
            if _title == "":
                _title = "%s:%d (%s)" % (request.dst_host if request.dst_host
                                         != "" else request.dst_ip,
                                         request.dst_port, request.protocol)

            node_text = "" if self._is_local_request(
                proto, addr) else "on node {0}:{1}".format(proto, addr)
            self._show_message_trigger.emit(
                _title, "{0} action applied {1}\nCommand line: {2}".format(
                    rule.action, node_text, " ".join(request.process_args)),
                QtWidgets.QSystemTrayIcon.NoIcon)

        if rule.duration in Config.RULES_DURATION_FILTER:
            self._node_actions_trigger.emit({
                'action': self.DELETE_RULE,
                'name': rule.name,
                'addr': context.peer()
            })
        else:
            self._node_actions_trigger.emit({
                'action': self.ADD_RULE,
                'peer': context.peer(),
                'rule': rule
            })

        return rule

    def Subscribe(self, node_config, context):
        """
        Accept and collect nodes. It keeps a connection open with each
        client, in order to send them notifications.

        @doc: https://grpc.github.io/grpc/python/grpc.html#service-side-context
        """
        # if the exit mark is set, don't accept new connections.
        # db vacuum operation may take a lot of time to complete.
        if self._exit:
            return
        try:
            self._node_actions_trigger.emit({
                'action': self.NODE_ADD,
                'peer': context.peer(),
                'node_config': node_config
            })
            # force events processing, to add the node ^ before the
            # Notifications() call arrives.
            self._app.processEvents()

            proto, addr = self._get_peer(context.peer())
            if self._is_local_request(proto, addr) == False:
                self._show_message_trigger.emit(
                    QtCore.QCoreApplication.translate("stats",
                                                      "New node connected"),
                    "({0})".format(context.peer()),
                    QtWidgets.QSystemTrayIcon.Information)
        except Exception as e:
            print("[Notifications] exception adding new node:", e)
            context.cancel()

        node_config.config = self._overwrite_nodes_config(node_config.config)

        return node_config

    def Notifications(self, node_iter, context):
        """
        Accept and collect nodes. It keeps a connection open with each
        client, in order to send them notifications.

        @doc: https://grpc.github.io/grpc/python/grpc.html#service-side-context
        @doc: https://grpc.io/docs/what-is-grpc/core-concepts/
        """
        proto, addr = self._get_peer(context.peer())
        _node = self._nodes.get_node("%s:%s" % (proto, addr))
        if _node == None:
            return

        stop_event = Event()

        def _on_client_closed():
            stop_event.set()
            self._node_actions_trigger.emit({
                'action': self.NODE_DELETE,
                'peer': context.peer(),
            })

            self._status_change_trigger.emit(False)
            # TODO: handle the situation when a node disconnects, and the
            # remaining node has the fw disabled.
            #if self._nodes.count() == 1:
            #    nd = self._nodes.get_nodes()
            #    if nd[0].get_config().isFirewallRunning:

            if self._is_local_request(proto, addr) == False:
                self._show_message_trigger.emit(
                    "node exited", "({0})".format(context.peer()),
                    QtWidgets.QSystemTrayIcon.Information)

        context.add_callback(_on_client_closed)

        # TODO: move to notifications.py
        def new_node_message():
            print("new node connected, listening for client responses...",
                  addr)

            while self._exit == False:
                try:
                    if stop_event.is_set():
                        break
                    in_message = next(node_iter)
                    if in_message == None:
                        continue

                    self._nodes.reply_notification(addr, in_message)
                except StopIteration:
                    print("[Notifications] Node {0} exited".format(addr))
                    break
                except grpc.RpcError as e:
                    print(
                        "[Notifications] grpc exception new_node_message(): ",
                        addr, in_message)
                except Exception as e:
                    print(
                        "[Notifications] unexpected exception new_node_message(): ",
                        addr, e, in_message)

        read_thread = Thread(target=new_node_message)
        read_thread.daemon = True
        read_thread.start()

        while self._exit == False:
            if stop_event.is_set():
                break

            try:
                noti = _node['notifications'].get()
                if noti != None:
                    _node['notifications'].task_done()
                    yield noti
            except Exception as e:
                print(
                    "[Notifications] exception getting notification from queue:",
                    addr, e)
                context.cancel()

        return node_iter