Beispiel #1
0
class UIService(ui_pb2_grpc.UIServicer, QtWidgets.QGraphicsObject):
    _new_remote_trigger = QtCore.pyqtSignal(str, ui_pb2.PingRequest)
    _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, int)

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

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

        self._cfg = Config.init()
        self._db = Database.instance()
        self._db.initialize(
            dbtype=self._cfg.getInt(self._cfg.DEFAULT_DB_TYPE_KEY),
            dbfile=self._cfg.getSettings(self._cfg.DEFAULT_DB_FILE_KEY))
        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._prompt_dialog = PromptDialog()
        self._remote_lock = Lock()
        self._remote_stats = {}

        self._setup_interfaces()
        self._setup_icons()
        self._stats_dialog = StatsDialog(dbname="general", db=self._db)
        self._setup_tray()
        self._setup_slots()

        self._nodes = Nodes.instance()

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

    # 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._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._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)
        self._prompt_dialog.setWindowIcon(self.white_icon)

    def _setup_tray(self):
        self._menu = QtWidgets.QMenu()
        self._tray = QtWidgets.QSystemTrayIcon(self.off_icon)
        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_URL)
                                                   ))
        self._menu.addAction(self.MENU_ENTRY_CLOSE).triggered.connect(
            self._on_close)

        self._tray.show()
        if not self._tray.isSystemTrayAvailable():
            self._stats_dialog.show()

    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._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, int)
    def _show_systray_message(self, title, body, icon, timeout):
        if icon == QtWidgets.QSystemTrayIcon.NoIcon:
            self._tray.setIcon(self.alert_icon)
        self._tray.showMessage(title, body, icon, timeout)

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

    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):
        fields = []
        values = []
        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] = []

            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")
                # TODO: move to nodes.add_node()
                # TODO: remove, and add them only ondemand
                db.insert(
                    "rules",
                    "(time, node, name, enabled, precedence, action, duration, operator_type, operator_sensitive, operator_operand, operator_data)",
                    (str(datetime.fromtimestamp(
                        event.unixnano / 1000000000)), "%s:%s" %
                     (proto, addr), event.rule.name, str(event.rule.enabled),
                     str(event.rule.precedence), event.rule.action,
                     event.rule.duration, event.rule.operator.type,
                     str(event.rule.operator.sensitive),
                     event.rule.operator.operand, event.rule.operator.data),
                    action_on_conflict="REPLACE")

            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 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):
        # 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())
        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}\nArguments: {2}".format(
                    rule.action, node_text, request.process_args),
                QtWidgets.QSystemTrayIcon.NoIcon, 0)

        self._last_ping = datetime.now()
        self._asking = False

        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
        """
        try:
            proto, addr = self._get_peer(context.peer())
            if self._is_local_request(proto, addr) == False:
                self._show_message_trigger.emit(
                    "New node connected", "({0})".format(context.peer()),
                    QtWidgets.QSystemTrayIcon.Information, 5000)
            n = self._nodes.add(context, node_config)

            if n != None:
                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(node_config.isFirewallRunning)
                else:
                    self._update_fw_status(True)

        except Exception as e:
            print("[Notifications] exception adding new node:", e)
            context.cancel()

        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))

        stop_event = Event()

        def _on_client_closed():
            stop_event.set()
            self._delete_node(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, 5000)

        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
Beispiel #2
0
class UIService(ui_pb2_grpc.UIServicer, QtWidgets.QGraphicsObject):
    _new_remote_trigger = QtCore.pyqtSignal(str, ui_pb2.Statistics)
    _version_warning_trigger = QtCore.pyqtSignal(str, str)
    _status_change_trigger = QtCore.pyqtSignal()

    def __init__(self, app, on_exit, config, tray_only_arg):
        super(UIService, self).__init__()
        self._db = Database.instance()

        self._cfg = Config.init(config)
        self._tray_only = tray_only_arg
        self._last_ping = None
        self._version_warning_shown = False
        self._asking = False
        self._connected = False
        self._path = os.path.abspath(os.path.dirname(__file__))
        self._app = app
        self._on_exit = on_exit
        self._msg = QtWidgets.QMessageBox()
        self._prompt_dialog = PromptDialog()
        self._stats_dialog = StatsDialog()
        self._remote_lock = Lock()
        self._remote_stats = {}

        # make sure we save the configuration if it
        # does not exist as a file yet
        if self._cfg.exists == False:
            self._cfg.save()

        self._setup_interfaces()
        self._setup_slots()
        self._setup_icons()
        self._setup_tray()

        self.check_thread = Thread(target=self._async_worker)
        self.check_thread.daemon = True
        self.check_thread.start()

        self.last_stats = None

    # https://gist.github.com/pklaus/289646
    def _setup_interfaces(self):
        max_possible = 128  # arbitrary. raise if needed.
        bytes = max_possible * 32
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        names = array.array('B', b'\0' * bytes)
        outbytes = struct.unpack(
            'iL',
            fcntl.ioctl(
                s.fileno(),
                0x8912,  # SIOCGIFCONF
                struct.pack('iL', bytes,
                            names.buffer_info()[0])))[0]
        namestr = names.tostring()
        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._status_change_trigger.connect(self._on_status_change)
        self._new_remote_trigger.connect(self._on_new_remote)

    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.png"))
        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._app.setWindowIcon(self.white_icon)
        self._prompt_dialog.setWindowIcon(self.white_icon)

    def _setup_tray(self):
        self._menu = QtWidgets.QMenu()
        self._stats_action = self._menu.addAction("Statistics")
        self._stats_action.triggered.connect(lambda: self._stats_dialog.show())
        self._menu.addAction("Close").triggered.connect(self._on_exit)
        self._tray = QtWidgets.QSystemTrayIcon(self.off_icon)
        self._tray.setContextMenu(self._menu)
        self._tray.show()
        if not self._tray.isSystemTrayAvailable():
            if not self._tray_only:
                self._stats_dialog.show()

    @QtCore.pyqtSlot()
    def _on_status_change(self):
        self._stats_dialog.daemon_connected = self._connected
        self._stats_dialog.update_status()
        if self._connected:
            self._tray.setIcon(self.white_icon)
        else:
            self._tray.setIcon(self.off_icon)

    @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, ui_pb2.Statistics)
    def _on_new_remote(self, addr, stats):
        print("_on_new_remote()")
        dialog = StatsDialog(address=addr)
        dialog.daemon_connected = True
        dialog.update(stats)
        self._remote_stats[addr] = dialog

        new_act = self._menu.addAction("%s Statistics" % addr)
        new_act.triggered.connect(lambda: self._on_remote_stats_menu(addr))
        self._menu.insertAction(self._stats_action, new_act)
        self._stats_action.setText("Local Statistics")

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

    def _async_worker(self):
        was_connected = False
        self._status_change_trigger.emit()

        while True:
            time.sleep(1)

            # we didn't see any daemon so far ...
            if self._last_ping is None:
                continue
            # a prompt is being shown, ping is on pause
            elif self._asking is True:
                continue

            # the daemon will ping the ui every second
            # we expect a 3 seconds delay -at most-
            time_not_seen = datetime.now() - self._last_ping
            secs_not_seen = time_not_seen.seconds + time_not_seen.microseconds / 1E6
            self._connected = (secs_not_seen < 3)
            if was_connected != self._connected:
                self._status_change_trigger.emit()
                was_connected = self._connected

    def _is_local_request(self, context):
        peer = context.peer()
        if peer.startswith("unix:"):
            return True

        elif peer.startswith("ipv4:"):
            _, addr, _ = peer.split(':')
            for name, ip in self._interfaces.items():
                if addr == ip:
                    return True

        return False

    def _populate_stats(self, db, stats):
        def count_hits(db, what_to_count: str, last_items):
            fields = []
            values = []
            items = stats.by_host.items()

            for _, event in enumerate(items):
                if self.last_stats is not None and event in last_items:
                    continue
                what, hits = event
                fields.append(what)
                values.append(int(hits))
            db.insert_batch(what_to_count, "(what, hits)", (1, 2), fields,
                            values)

        fields = []
        values = []

        for _, event in enumerate(stats.events):
            if self.last_stats is not None and event in self.last_stats.events:
                continue
            db.insert(
                "connections",
                "(time, action, protocol, src_ip, src_port, dst_ip, dst_host, dst_port, uid, process, process_args, rule)",
                (event.time, 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),
                 event.connection.process_path, " ".join(
                     event.connection.process_args), event.rule.name),
                action_on_conflict="IGNORE")
            db.insert("rules",
                      "(time, name, action, duration, operator)",
                      (datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
                       event.rule.name, event.rule.action, event.rule.duration,
                       event.rule.operator.operand + ": " +
                       event.rule.operator.data),
                      action_on_conflict="IGNORE")

        count_hits(
            db, "hosts",
            self.last_stats.by_host.items()
            if self.last_stats is not None else '')
        count_hits(
            db, "procs",
            self.last_stats.by_executable.items()
            if self.last_stats is not None else '')
        count_hits(
            db, "addrs",
            self.last_stats.by_address.items()
            if self.last_stats is not None else '')
        count_hits(
            db, "ports",
            self.last_stats.by_port.items()
            if self.last_stats is not None else '')

        items = stats.by_uid.items()
        last_items = self.last_stats.by_uid.items(
        ) if self.last_stats is not None else ''
        for row, event in enumerate(items):
            if self.last_stats is not None and event in last_items:
                continue
            what, hits = event
            pw_name = what
            try:
                pw_name = pwd.getpwuid(int(what)).pw_name + " (" + what + ")"
            except Exception:
                pw_name += " (error)"
            fields.append(pw_name)
            values.append(int(hits))
        db.insert_batch("users", "(what, hits)", (1, 2), fields, values)

        self.last_stats = stats

    def Ping(self, request, context):
        if self._is_local_request(context):
            self._last_ping = datetime.now()
            self._populate_stats(self._db, request.stats)
            self._stats_dialog.update(request.stats)

            if request.stats.daemon_version != version:
                self._version_warning_trigger.emit(
                    request.stats.daemon_version, version)
        else:
            with self._remote_lock:
                _, addr, _ = context.peer().split(':')
                if addr in self._remote_stats:
                    self._populate_stats(self._db, request.stats)
                    self._remote_stats[addr].update(request.stats)
                else:
                    self._new_remote_trigger.emit(addr, request.stats)
        return ui_pb2.PingReply(id=request.id)

    def AskRule(self, request, context):
        self._asking = True
        rule = self._prompt_dialog.promptUser(request,
                                              self._is_local_request(context),
                                              context.peer())
        self._last_ping = datetime.now()
        self._asking = False
        return rule
Beispiel #3
0
class UIService(ui_pb2_grpc.UIServicer, QtWidgets.QGraphicsObject):
    _new_remote_trigger = QtCore.pyqtSignal(str, ui_pb2.PingRequest)
    _update_stats_trigger = QtCore.pyqtSignal(str, str, ui_pb2.PingRequest)
    _version_warning_trigger = QtCore.pyqtSignal(str, str)
    _status_change_trigger = QtCore.pyqtSignal()

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

        self._db = Database.instance()
        self._db_sqlite = self._db.get_db()
        self._cfg = Config.init()
        self._last_ping = None
        self._version_warning_shown = False
        self._asking = False
        self._connected = 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._prompt_dialog = PromptDialog()
        self._stats_dialog = StatsDialog(dbname="general", db=self._db)
        self._remote_lock = Lock()
        self._remote_stats = {}

        self._setup_interfaces()
        self._setup_slots()
        self._setup_icons()
        self._setup_tray()

        self.check_thread = Thread(target=self._async_worker)
        self.check_thread.daemon = True
        self.check_thread.start()

        self._nodes = Nodes.instance()

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

    # https://gist.github.com/pklaus/289646
    def _setup_interfaces(self):
        max_possible = 128  # arbitrary. raise if needed.
        bytes = max_possible * 32
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        names = array.array('B', b'\0' * bytes)
        outbytes = struct.unpack('iL', fcntl.ioctl(
            s.fileno(),
            0x8912,  # SIOCGIFCONF
            struct.pack('iL', bytes, names.buffer_info()[0])
        ))[0]
        namestr = names.tobytes()
        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._status_change_trigger.connect(self._on_status_change)
        self._new_remote_trigger.connect(self._on_new_remote)
        self._update_stats_trigger.connect(self._on_update_stats)
        self._stats_dialog._shown_trigger.connect(self._on_stats_dialog_shown)

    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.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)
        self._prompt_dialog.setWindowIcon(self.white_icon)

    def _setup_tray(self):
        self._menu = QtWidgets.QMenu()
        self._stats_action = self._menu.addAction("Statistics")

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

        self._menu.addAction("Help").triggered.connect(
                lambda: QtGui.QDesktopServices.openUrl(QtCore.QUrl(Config.HELP_URL))
                )

        self._stats_action.triggered.connect(self._show_stats_dialog)
        self._menu.addAction("Close").triggered.connect(self._on_close)

        self._tray.show()
        if not self._tray.isSystemTrayAvailable():
            self._stats_dialog.show()

    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._on_exit()

    def _show_stats_dialog(self):
        self._tray.setIcon(self.white_icon)
        self._stats_dialog.show()

    @QtCore.pyqtSlot()
    def _on_status_change(self):
        self._stats_dialog.daemon_connected = self._connected
        self._stats_dialog.update_status()
        if self._connected:
            self._tray.setIcon(self.white_icon)
        else:
            self._tray.setIcon(self.off_icon)

    @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:
            self._tray.setIcon(self.white_icon)
        else:
            self._tray.setIcon(self.off_icon)

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

    def _async_worker(self):
        was_connected = False
        self._status_change_trigger.emit()

        while True:
            time.sleep(1)

            # we didn't see any daemon so far ...
            if self._last_ping is None:
                continue
            # a prompt is being shown, ping is on pause
            elif self._asking is True:
                continue

            # the daemon will ping the ui every second
            # we expect a 3 seconds delay -at most-
            time_not_seen = datetime.now() - self._last_ping
            secs_not_seen = time_not_seen.seconds + time_not_seen.microseconds / 1E6
            self._connected = ( secs_not_seen < 3 )
            if was_connected != self._connected:
                self._status_change_trigger.emit()
                was_connected = self._connected

    def _check_versions(self, daemon_version):
        lMayor, lMinor, lPatch = version.split(".")
        rMayor, rMinor, rPatch = daemon_version.split(".")
        if lMayor != rMayor or (lMayor == rMayor and lMinor != rMinor):
            self._version_warning_trigger.emit(daemon_version, version)

    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_user_id(self, uid):
        pw_name = uid
        try:
            pw_name = pwd.getpwuid(int(uid)).pw_name + " (" + uid + ")"
        except Exception:
            #pw_name += " (error)"
            pass

        return pw_name

    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
        """
        p = peer.split(":")
        # WA for backward compatibility
        if p[0] == "unix" and p[1] == "":
            p[1] = "local"
        return p[0], p[1]

    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):
        fields = []
        values = []
        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] = []

            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"
                        )
                # TODO: move to nodes.add_node()
                # TODO: remove, and add them only ondemand
                db.insert("rules",
                        "(time, node, name, enabled, precedence, action, duration, operator_type, operator_sensitive, operator_operand, operator_data)",
                            (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "%s:%s" % (proto, addr),
                                event.rule.name, str(event.rule.enabled), str(event.rule.precedence),
                                event.rule.action, event.rule.duration,
                                event.rule.operator.type, str(event.rule.operator.sensitive),
                                event.rule.operator.operand, event.rule.operator.data),
                        action_on_conflict="IGNORE")

            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 = self._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 Ping(self, request, context):
        try:
            self._last_ping = datetime.now()
            self._check_versions(request.stats.daemon_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):
        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())
        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)

            self._tray.setIcon(self.alert_icon)
            self._tray.showMessage(_title, "%s action applied\nArguments: %s" % (rule.action, request.process_args), QtWidgets.QSystemTrayIcon.NoIcon, 0)

        self._last_ping = datetime.now()
        self._asking = False

        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
        """
        try:
            n = self._nodes.add(context, node_config)
        except Exception as e:
            print("[Notifications] exception adding new node:", e)
            context.cancel()

        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
        """
        proto, addr = self._get_peer(context.peer())
        _node = self._nodes.get_node("%s:%s" % (proto, addr))

        stop_event = Event()
        def _on_client_closed():
            stop_event.set()
            self._delete_node(context.peer())

        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 as e:
                    print("[Notifications] Node exited")
                except Exception as e:
                    print("[Notifications] exception new_node_message(): ", addr, e)

        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
Beispiel #4
0
class UIService(ui_pb2_grpc.UIServicer, QtWidgets.QGraphicsObject):
    _new_remote_trigger = QtCore.pyqtSignal(str, ui_pb2.Statistics)
    _version_warning_trigger = QtCore.pyqtSignal(str, str)
    _status_change_trigger = QtCore.pyqtSignal()

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

        self._cfg = Config.init(config)
        self._last_ping = None
        self._version_warning_shown = False
        self._asking = False
        self._connected = False
        self._path = os.path.abspath(os.path.dirname(__file__))
        self._app = app
        self._on_exit = on_exit
        self._msg = QtWidgets.QMessageBox()
        self._prompt_dialog = PromptDialog()
        self._stats_dialog = StatsDialog()
        self._remote_lock = Lock()
        self._remote_stats = {}

        # make sure we save the configuration if it
        # does not exist as a file yet
        if self._cfg.exists == False:
            self._cfg.save()

        self._setup_interfaces()
        self._setup_slots()
        self._setup_icons()
        self._setup_tray()

        self.check_thread = Thread(target=self._async_worker)
        self.check_thread.daemon = True
        self.check_thread.start()

    # https://gist.github.com/pklaus/289646
    def _setup_interfaces(self):
        max_possible = 128  # arbitrary. raise if needed.
        bytes = max_possible * 32
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        names = array.array('B', b'\0' * bytes)
        outbytes = struct.unpack(
            'iL',
            fcntl.ioctl(
                s.fileno(),
                0x8912,  # SIOCGIFCONF
                struct.pack('iL', bytes,
                            names.buffer_info()[0])))[0]
        namestr = names.tostring()
        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._status_change_trigger.connect(self._on_status_change)
        self._new_remote_trigger.connect(self._on_new_remote)

    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.png"))
        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._app.setWindowIcon(self.white_icon)
        self._prompt_dialog.setWindowIcon(self.white_icon)

    def _setup_tray(self):
        self._menu = QtWidgets.QMenu()
        self._stats_action = self._menu.addAction("Statistics")
        self._stats_action.triggered.connect(lambda: self._stats_dialog.show())
        self._menu.addAction("Close").triggered.connect(self._on_exit)
        self._tray = QtWidgets.QSystemTrayIcon(self.off_icon)
        self._tray.setContextMenu(self._menu)
        self._tray.show()

    @QtCore.pyqtSlot()
    def _on_status_change(self):
        self._stats_dialog.daemon_connected = self._connected
        self._stats_dialog.update()
        if self._connected:
            self._tray.setIcon(self.white_icon)
        else:
            self._tray.setIcon(self.off_icon)

    @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, ui_pb2.Statistics)
    def _on_new_remote(self, addr, stats):
        dialog = StatsDialog(address=addr)
        dialog.daemon_connected = True
        dialog.update(stats)
        self._remote_stats[addr] = dialog

        new_act = self._menu.addAction("%s Statistics" % addr)
        new_act.triggered.connect(lambda: self._on_remote_stats_menu(addr))
        self._menu.insertAction(self._stats_action, new_act)
        self._stats_action.setText("Local Statistics")

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

    def _async_worker(self):
        was_connected = False
        self._status_change_trigger.emit()

        while True:
            time.sleep(1)

            # we didn't see any daemon so far ...
            if self._last_ping is None:
                continue
            # a prompt is being shown, ping is on pause
            elif self._asking is True:
                continue

            # the daemon will ping the ui every second
            # we expect a 3 seconds delay -at most-
            time_not_seen = datetime.now() - self._last_ping
            secs_not_seen = time_not_seen.seconds + time_not_seen.microseconds / 1E6
            self._connected = (secs_not_seen < 3)
            if was_connected != self._connected:
                self._status_change_trigger.emit()
                was_connected = self._connected

    def _is_local_request(self, context):
        peer = context.peer()
        if peer.startswith("unix:"):
            return True

        elif peer.startswith("ipv4:"):
            _, addr, _ = peer.split(':')
            for name, ip in self._interfaces.items():
                if addr == ip:
                    return True

        return False

    def Ping(self, request, context):
        if self._is_local_request(context):
            self._last_ping = datetime.now()
            self._stats_dialog.update(request.stats)

            if request.stats.daemon_version != version:
                self._version_warning_trigger.emit(
                    request.stats.daemon_version, version)
        else:
            with self._remote_lock:
                _, addr, _ = context.peer().split(':')
                if addr in self._remote_stats:
                    self._remote_stats[addr].update(request.stats)
                else:
                    self._new_remote_trigger.emit(addr, request.stats)

        return ui_pb2.PingReply(id=request.id)

    def AskRule(self, request, context):
        self._asking = True
        rule = self._prompt_dialog.promptUser(request,
                                              self._is_local_request(context),
                                              context.peer())
        self._last_ping = datetime.now()
        self._asking = False
        return rule