From 8b9029aeb8a2e17183363b4b1f5109be52c9b28c Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 20:30:58 -0700 Subject: [PATCH 01/24] ui: make the protobuffers importable without Qt opensnitch/proto/__init__.py used opensnitch.utils.Versions to read the installed protobuf version, and opensnitch/utils/__init__.py imports PyQt6 at module scope, so importing the protobuffers pulled Qt in with them. Read google.protobuf.__version__ directly instead; the fallback behaviour is unchanged. That import also happened to be what kept a circular import working: config -> database -> utils -> config. It only ever resolved because something imported opensnitch.proto, and therefore opensnitch.utils, first, which is why the tests carry an "import proto first to avoid circular import issues" comment. Import Config, Database, Themes and LinuxDesktopParser from the functions that use them so the cycle is broken properly and the import order no longer matters. Co-Authored-By: Claude Opus 5 --- ui/opensnitch/proto/__init__.py | 7 +++++-- ui/opensnitch/utils/__init__.py | 15 +++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/ui/opensnitch/proto/__init__.py b/ui/opensnitch/proto/__init__.py index c712d44943..0d66303be1 100644 --- a/ui/opensnitch/proto/__init__.py +++ b/ui/opensnitch/proto/__init__.py @@ -18,7 +18,6 @@ from packaging.version import Version import importlib -from opensnitch.utils import Versions # Protobuffers compiled with protobuf < 3.20.0 are incompatible with # protobuf >= 4.0.0 @@ -43,7 +42,11 @@ def import_(): installed in the system. """ try: - gui_version, grpc_version, proto_version = Versions.get() + # read the protobuf version directly instead of using + # opensnitch.utils.Versions, so that the protobuffers can be imported + # without Qt installed (opensnitch-cli). + from google.protobuf import __version__ as proto_version + proto_ver = default_pb grpc_ver = default_grpc diff --git a/ui/opensnitch/utils/__init__.py b/ui/opensnitch/utils/__init__.py index 471877086d..b2ea1c930c 100644 --- a/ui/opensnitch/utils/__init__.py +++ b/ui/opensnitch/utils/__init__.py @@ -11,10 +11,10 @@ from PyQt6 import QtCore, QtWidgets, QtGui from opensnitch.version import version as gui_version -from opensnitch.database import Database -from opensnitch.config import Config -from opensnitch.utils.themes import Themes -from opensnitch.desktop_parser import LinuxDesktopParser +# Database, Config, Themes and LinuxDesktopParser are imported where they're +# used instead of here. Importing them at this point creates a circular import +# (config -> database -> utils -> config), which only worked as long as some +# other module happened to import one of them first. class AsnDB(): __instance = None @@ -159,6 +159,9 @@ class CleanerTask(Thread): callback = None def __init__(self, _interval, _callback): + from opensnitch.config import Config + from opensnitch.database import Database + Thread.__init__(self, name="cleaner_db_thread") self.interval = _interval * 60 self.stop_flag = Event() @@ -431,6 +434,8 @@ class Icons(): @staticmethod def new(widget, icon_name): + from opensnitch.utils.themes import Themes + if Themes.IS_DARK: icon_pix = os.path.join( os.path.abspath(os.path.dirname(__file__)), @@ -470,6 +475,8 @@ def get_by_appname(app_icon): icon = QtGui.QIcon(app_icon) pixmap = icon.pixmap(icon.actualSize(QtCore.QSize(48, 48))) else: + from opensnitch.desktop_parser import LinuxDesktopParser + icon_path = LinuxDesktopParser.discover_app_icon(app_icon) if icon_path != None: icon = QtGui.QIcon(icon_path) From fcfb75e72b54d9905a59722ad625837e4726933f Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 20:31:12 -0700 Subject: [PATCH 02/24] ui: move the rule constants to a module that doesn't need Qt The operands, rule types, actions and durations the daemon understands are plain strings, but they lived in Config, which imports PyQt6. Move them to opensnitch/rule_consts.py and let Config inherit from it, so every existing Config.OPERAND_*, Config.RULE_TYPE_*, Config.ACTION_* and Config.DURATION_* reference keeps resolving exactly as before. RULES_DURATION_FILTER and RULES_ACTIVE_TEMPORARY_RULES stay in Config, because setRulesDurationFilter() rebinds them on the class itself. Co-Authored-By: Claude Opus 5 --- ui/opensnitch/config.py | 67 ++------------------------- ui/opensnitch/rule_consts.py | 90 ++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 63 deletions(-) create mode 100644 ui/opensnitch/rule_consts.py diff --git a/ui/opensnitch/config.py b/ui/opensnitch/config.py index 93df00c1c5..862d9650a4 100644 --- a/ui/opensnitch/config.py +++ b/ui/opensnitch/config.py @@ -1,7 +1,8 @@ from PyQt6 import QtCore from opensnitch.database import Database +from opensnitch.rule_consts import RuleConsts -class Config: +class Config(RuleConsts): __instance = None HELP_URL = "https://github.com/evilsocket/opensnitch/wiki/" @@ -11,78 +12,18 @@ class Config: HELP_CONFIG_URL = "https://github.com/evilsocket/opensnitch/wiki/Configurations" HELP_SYSTRAY_WARN = "https://github.com/evilsocket/opensnitch/wiki/GUI-known-problems#gui-does-not-show-up" - OPERAND_PROCESS_ID = "process.id" - OPERAND_PROCESS_PATH = "process.path" - OPERAND_PROCESS_COMMAND = "process.command" - OPERAND_PROCESS_ENV = "process.env." - OPERAND_PROCESS_HASH_MD5 = "process.hash.md5" - OPERAND_PROCESS_HASH_SHA1 = "process.hash.sha1" - OPERAND_USER_ID = "user.id" - OPERAND_IFACE_OUT = "iface.out" - OPERAND_IFACE_IN = "iface.in" - OPERAND_SOURCE_IP = "source.ip" - OPERAND_SOURCE_PORT = "source.port" - OPERAND_DEST_IP = "dest.ip" - OPERAND_DEST_HOST = "dest.host" - OPERAND_DEST_PORT = "dest.port" - OPERAND_DEST_NETWORK = "dest.network" - OPERAND_SOURCE_NETWORK = "source.network" - OPERAND_PROTOCOL = "protocol" - OPERAND_LIST_DOMAINS = "lists.domains" - OPERAND_LIST_DOMAINS_REGEXP = "lists.domains_regexp" - OPERAND_LIST_IPS = "lists.ips" - OPERAND_LIST_NETS = "lists.nets" - - RULE_TYPE_LIST = "list" - RULE_TYPE_LISTS = "lists" - RULE_TYPE_SIMPLE = "simple" - RULE_TYPE_REGEXP = "regexp" - RULE_TYPE_NETWORK = "network" - RULE_TYPE_RANGE = "range" - RulesTypes = (RULE_TYPE_LIST, RULE_TYPE_LISTS, RULE_TYPE_SIMPLE, RULE_TYPE_REGEXP, RULE_TYPE_NETWORK, RULE_TYPE_RANGE) + # rule operands, types, actions and durations are defined in + # opensnitch.rule_consts.RuleConsts, which has no Qt dependency. DEFAULT_TARGET_PROCESS = 0 ACTION_DROP_IDX = 0 ACTION_ALLOW_IDX = 1 ACTION_REJECT_IDX = 2 - # don't translate - ACTION_ALLOW = "allow" - ACTION_DENY = "deny" - ACTION_REJECT = "reject" - ACTION_ACCEPT = "accept" - ACTION_DROP = "drop" - ACTION_JUMP = "jump" - ACTION_REDIRECT = "redirect" - ACTION_RETURN = "return" - ACTION_TPROXY = "tproxy" - ACTION_SNAT = "snat" - ACTION_DNAT = "dnat" - ACTION_MASQUERADE = "masquerade" - ACTION_QUEUE = "queue" - ACTION_LOG = "log" - ACTION_STOP = "stop" - - DURATION_FIELD = "duration" - DURATION_UNTIL_RESTART = "until restart" - DURATION_ALWAYS = "always" - DURATION_ONCE = "once" - DURATION_12h = "12h" - DURATION_1h = "1h" - DURATION_30m = "30m" - DURATION_15m = "15m" - DURATION_5m = "5m" - DURATION_30s = "30s" - # Rules of this list are ignored/deleted RULES_DURATION_FILTER = () # Rules of this list are active RULES_ACTIVE_TEMPORARY_RULES = () - RULES_TEMPORARY_LIST = [ - DURATION_ONCE, DURATION_30s, DURATION_5m, - DURATION_15m, DURATION_30m, DURATION_1h, - DURATION_12h, - DURATION_UNTIL_RESTART] DEFAULT_DURATION_IDX = 6 # until restart diff --git a/ui/opensnitch/rule_consts.py b/ui/opensnitch/rule_consts.py new file mode 100644 index 0000000000..1c418e8f25 --- /dev/null +++ b/ui/opensnitch/rule_consts.py @@ -0,0 +1,90 @@ +# Copyright (C) 2026 The OpenSnitch Authors +# +# This file is part of OpenSnitch. +# +# OpenSnitch is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenSnitch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenSnitch. If not, see . + +class RuleConsts: + """Rule operands, types, actions and durations, as understood by the daemon. + + These constants have no Qt dependency, so they can be used by components + that run without a graphical environment (opensnitch-cli). Config inherits + from this class, so Config.OPERAND_* and friends keep working as before. + + The daemon side counterpart is daemon/rule/operator.go and daemon/rule/rule.go + """ + + OPERAND_PROCESS_ID = "process.id" + OPERAND_PROCESS_PATH = "process.path" + OPERAND_PROCESS_COMMAND = "process.command" + OPERAND_PROCESS_ENV = "process.env." + OPERAND_PROCESS_HASH_MD5 = "process.hash.md5" + OPERAND_PROCESS_HASH_SHA1 = "process.hash.sha1" + OPERAND_USER_ID = "user.id" + OPERAND_IFACE_OUT = "iface.out" + OPERAND_IFACE_IN = "iface.in" + OPERAND_SOURCE_IP = "source.ip" + OPERAND_SOURCE_PORT = "source.port" + OPERAND_DEST_IP = "dest.ip" + OPERAND_DEST_HOST = "dest.host" + OPERAND_DEST_PORT = "dest.port" + OPERAND_DEST_NETWORK = "dest.network" + OPERAND_SOURCE_NETWORK = "source.network" + OPERAND_PROTOCOL = "protocol" + OPERAND_LIST_DOMAINS = "lists.domains" + OPERAND_LIST_DOMAINS_REGEXP = "lists.domains_regexp" + OPERAND_LIST_IPS = "lists.ips" + OPERAND_LIST_NETS = "lists.nets" + + RULE_TYPE_LIST = "list" + RULE_TYPE_LISTS = "lists" + RULE_TYPE_SIMPLE = "simple" + RULE_TYPE_REGEXP = "regexp" + RULE_TYPE_NETWORK = "network" + RULE_TYPE_RANGE = "range" + RulesTypes = (RULE_TYPE_LIST, RULE_TYPE_LISTS, RULE_TYPE_SIMPLE, RULE_TYPE_REGEXP, RULE_TYPE_NETWORK, RULE_TYPE_RANGE) + + # don't translate + ACTION_ALLOW = "allow" + ACTION_DENY = "deny" + ACTION_REJECT = "reject" + ACTION_ACCEPT = "accept" + ACTION_DROP = "drop" + ACTION_JUMP = "jump" + ACTION_REDIRECT = "redirect" + ACTION_RETURN = "return" + ACTION_TPROXY = "tproxy" + ACTION_SNAT = "snat" + ACTION_DNAT = "dnat" + ACTION_MASQUERADE = "masquerade" + ACTION_QUEUE = "queue" + ACTION_LOG = "log" + ACTION_STOP = "stop" + + DURATION_FIELD = "duration" + DURATION_UNTIL_RESTART = "until restart" + DURATION_ALWAYS = "always" + DURATION_ONCE = "once" + DURATION_12h = "12h" + DURATION_1h = "1h" + DURATION_30m = "30m" + DURATION_15m = "15m" + DURATION_5m = "5m" + DURATION_30s = "30s" + + RULES_TEMPORARY_LIST = [ + DURATION_ONCE, DURATION_30s, DURATION_5m, + DURATION_15m, DURATION_30m, DURATION_1h, + DURATION_12h, + DURATION_UNTIL_RESTART] From beb88a6eb6bf57a396987fbd9da0acd247ea0623 Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 20:31:12 -0700 Subject: [PATCH 03/24] ui: move the operand builders out of the pop-up get_combo_operator() turns the entry picked in the "apply to" combo into the operator of a rule, including the destination host and address wildcards and the appimage and snap path patterns. That logic is useful outside the pop-up, so move it to opensnitch/operands.py, which has no Qt dependency, and leave get_combo_operator() as the adapter that maps what the combo displays onto it. The field identifiers move with it and are re-exported from the pop-up's constants, so constants.FIELD_* keeps working. No change in behaviour: the operator built for a given connection and combo entry is identical, which ui/tests/cli/test_operands_parity.py checks. Co-Authored-By: Claude Opus 5 --- ui/opensnitch/dialogs/prompt/constants.py | 27 +-- ui/opensnitch/dialogs/prompt/utils.py | 66 ++--- ui/opensnitch/operands.py | 283 ++++++++++++++++++++++ 3 files changed, 307 insertions(+), 69 deletions(-) create mode 100644 ui/opensnitch/operands.py diff --git a/ui/opensnitch/dialogs/prompt/constants.py b/ui/opensnitch/dialogs/prompt/constants.py index 7ecba064fe..f23ca230e9 100644 --- a/ui/opensnitch/dialogs/prompt/constants.py +++ b/ui/opensnitch/dialogs/prompt/constants.py @@ -1,5 +1,15 @@ from PyQt6.QtCore import QCoreApplication as QC +# the fields a connection can be matched on, and the patterns built out of +# them, are defined in opensnitch.operands, so that opensnitch-cli can reuse +# them without pulling in Qt. Re-exported here for convenience. +from opensnitch.operands import ( + FIELD_REGEX_HOST, FIELD_REGEX_IP, FIELD_PROC_PATH, FIELD_PROC_ARGS, + FIELD_PROC_ID, FIELD_USER_ID, FIELD_DST_IP, FIELD_DST_PORT, + FIELD_DST_NETWORK, FIELD_DST_HOST, FIELD_APPIMAGE, FIELD_SNAP, + APPIMAGE_PREFIX, SNAP_PREFIX +) + PAGE_MAIN = 2 PAGE_DETAILS = 0 PAGE_CHECKSUMS = 1 @@ -8,20 +18,6 @@ DEFAULT_TIMEOUT = 15 -# don't translate -FIELD_REGEX_HOST = "regex_host" -FIELD_REGEX_IP = "regex_ip" -FIELD_PROC_PATH = "process_path" -FIELD_PROC_ARGS = "process_args" -FIELD_PROC_ID = "process_id" -FIELD_USER_ID = "user_id" -FIELD_DST_IP = "dst_ip" -FIELD_DST_PORT = "dst_port" -FIELD_DST_NETWORK = "dst_network" -FIELD_DST_HOST = "simple_host" -FIELD_APPIMAGE = "appimage_path" -FIELD_SNAP = "snap_path" - TARGET_IDX_PROC_PATH = 0 TARGET_IDX_PROC_CMDLINE = 1 TARGET_IDX_DST_PORT = 2 @@ -37,9 +33,6 @@ DURATION_12h = "12h" # don't translate -APPIMAGE_PREFIX = "/tmp/.mount_" -SNAP_PREFIX = "/snap" - # label displayed in the pop-up combo DURATION_session = QC.translate("popups", "until reboot") # label displayed in the pop-up combo diff --git a/ui/opensnitch/dialogs/prompt/utils.py b/ui/opensnitch/dialogs/prompt/utils.py index a362748cf9..6355dc646e 100644 --- a/ui/opensnitch/dialogs/prompt/utils.py +++ b/ui/opensnitch/dialogs/prompt/utils.py @@ -4,6 +4,7 @@ from PyQt6.QtCore import QCoreApplication as QC +from opensnitch import operands from opensnitch.config import Config from opensnitch.dialogs.prompt import constants from opensnitch.utils.network_aliases import NetworkAliases @@ -264,64 +265,25 @@ def set_default_target(combo, con, cfg, app_name, app_args): combo.setCurrentIndex(constants.TARGET_IDX_DST_PORT) def get_combo_operator(data, comboText, con): - if data == constants.FIELD_PROC_PATH: - return Config.RULE_TYPE_SIMPLE, Config.OPERAND_PROCESS_PATH, con.process_path + """builds the operator of the rule out of the entry selected in the combo. - elif data == constants.FIELD_PROC_ARGS: - # this should not happen - if len(con.process_args) == 0 or con.process_args[0] == "": - return Config.RULE_TYPE_SIMPLE, Config.OPERAND_PROCESS_PATH, con.process_path - return Config.RULE_TYPE_SIMPLE, Config.OPERAND_PROCESS_COMMAND, ' '.join(con.process_args) - - elif data == constants.FIELD_PROC_ID: - return Config.RULE_TYPE_SIMPLE, Config.OPERAND_PROCESS_ID, "{0}".format(con.process_id) - - elif data == constants.FIELD_USER_ID: - return Config.RULE_TYPE_SIMPLE, Config.OPERAND_USER_ID, "{0}".format(con.user_id) - - elif data == constants.FIELD_DST_PORT: - return Config.RULE_TYPE_SIMPLE, Config.OPERAND_DEST_PORT, "{0}".format(con.dst_port) - - elif data == constants.FIELD_DST_IP: - return Config.RULE_TYPE_SIMPLE, Config.OPERAND_DEST_IP, con.dst_ip - - elif data == constants.FIELD_DST_HOST: - return Config.RULE_TYPE_SIMPLE, Config.OPERAND_DEST_HOST, comboText + The patterns themselves are built by opensnitch.operands, shared with + opensnitch-cli. This only translates what the combo displays to the value + those builders expect. + """ + value = comboText - elif data == constants.FIELD_DST_NETWORK: + if data == constants.FIELD_DST_NETWORK or data == constants.FIELD_REGEX_IP: # strip "to ": "to x.x.x/20" -> "x.x.x/20" # we assume that to is one word in all languages parts = comboText.split(' ') - text = parts[len(parts)-1] - return Config.RULE_TYPE_NETWORK, Config.OPERAND_DEST_NETWORK, text + value = parts[len(parts)-1] elif data == constants.FIELD_REGEX_HOST: + # strip "to " and the wildcard: "to *.yahoo.com" -> "yahoo.com" parts = comboText.split(' ') - text = parts[len(parts)-1] - # ^(|.*\.)yahoo\.com - dsthost = r'\.'.join(text.split('.')).replace("*", "") - dsthost = r'^(|.*\.)%s$' % dsthost[2:] - return Config.RULE_TYPE_REGEXP, Config.OPERAND_DEST_HOST, dsthost + value = parts[len(parts)-1] + if value.startswith("*."): + value = value[2:] - elif data == constants.FIELD_REGEX_IP: - parts = comboText.split(' ') - text = parts[len(parts)-1] - return Config.RULE_TYPE_REGEXP, Config.OPERAND_DEST_IP, "%s" % r'\.'.join(text.split('.')).replace("*", ".*") - - elif data == constants.FIELD_APPIMAGE: - appimage_bin = os.path.basename(con.process_path) - appimage_path = os.path.dirname(con.process_path).replace('.', r'\.') - appimage_path = appimage_path[0:len(constants.APPIMAGE_PREFIX)+7] - # usually appimages add 6 random characters after the prefix, but - # some appimages do not follow this rule (Eden appimage for example, - # #1377). - return Config.RULE_TYPE_REGEXP, Config.OPERAND_PROCESS_PATH, r'^{0}[0-9A-Za-z]+\/.*{1}$'.format(appimage_path, appimage_bin) - - elif data == constants.FIELD_SNAP: - snap_path = con.process_path - snap_parts = snap_path.split('/') - snap_prefix = snap_parts[1] - app = snap_parts[2] - app_path = r'\/'.join(snap_parts[4:]) - regexp = r'^\/{0}\/{1}\/[0-9]+\/{2}$'.format(snap_prefix, app, app_path) - return Config.RULE_TYPE_REGEXP, Config.OPERAND_PROCESS_PATH, regexp + return operands.get_operator(data, value, con) diff --git a/ui/opensnitch/operands.py b/ui/opensnitch/operands.py new file mode 100644 index 0000000000..22062fadb8 --- /dev/null +++ b/ui/opensnitch/operands.py @@ -0,0 +1,283 @@ +# Copyright (C) 2026 The OpenSnitch Authors +# +# This file is part of OpenSnitch. +# +# OpenSnitch is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenSnitch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenSnitch. If not, see . + +"""Builds rule operators out of a connection. + +This is the logic behind the "apply to" selector of the pop-up dialog, with +the Qt parts left out, so that it can be shared with components that run +without a graphical environment (opensnitch-cli). + +Every builder returns a (type, operand, data) tuple, ready to be assigned to a +ui_pb2.Operator. The daemon side counterpart is daemon/rule/operator.go +""" + +import ipaddress +import os +import re + +from opensnitch.rule_consts import RuleConsts + + +def _get_network_alias(dst_ip): + """network aliases live under opensnitch.utils, which needs Qt. + + Import it only when it's available, so that this module keeps working on + systems without a graphical environment. + """ + try: + from opensnitch.utils.network_aliases import NetworkAliases + return NetworkAliases.get_alias(dst_ip) + except ImportError: + return None + +# Identifiers of the fields a connection can be matched on. They are stored as +# the userData of the pop-up combo boxes, so don't translate them and don't +# change their values. +FIELD_REGEX_HOST = "regex_host" +FIELD_REGEX_IP = "regex_ip" +FIELD_PROC_PATH = "process_path" +FIELD_PROC_ARGS = "process_args" +FIELD_PROC_ID = "process_id" +FIELD_USER_ID = "user_id" +FIELD_DST_IP = "dst_ip" +FIELD_DST_PORT = "dst_port" +FIELD_DST_NETWORK = "dst_network" +FIELD_DST_HOST = "simple_host" +FIELD_APPIMAGE = "appimage_path" +FIELD_SNAP = "snap_path" + +APPIMAGE_PREFIX = "/tmp/.mount_" +SNAP_PREFIX = "/snap" + +# Constructs that Python's re accepts but Go's RE2 engine does not. A rule +# using any of them is silently rejected by the daemon, so warn about it before +# sending it. @doc: https://github.com/google/re2/wiki/Syntax +RE2_UNSUPPORTED = ( + (r'(?=', "lookahead"), + (r'(?!', "negative lookahead"), + (r'(?<=', "lookbehind"), + (r'(?', "atomic group"), +) +_BACKREF_RE = re.compile(r'\\[1-9]') + + +def from_process_path(process_path): + return RuleConsts.RULE_TYPE_SIMPLE, RuleConsts.OPERAND_PROCESS_PATH, process_path + +def from_process_command(process_args, process_path): + """matches on the whole command line. + + Falls back to the executable path when the arguments are not available, + which is what the pop-up does. + """ + if len(process_args) == 0 or process_args[0] == "": + return from_process_path(process_path) + return RuleConsts.RULE_TYPE_SIMPLE, RuleConsts.OPERAND_PROCESS_COMMAND, ' '.join(process_args) + +def from_process_id(process_id): + return RuleConsts.RULE_TYPE_SIMPLE, RuleConsts.OPERAND_PROCESS_ID, "{0}".format(process_id) + +def from_user_id(user_id): + return RuleConsts.RULE_TYPE_SIMPLE, RuleConsts.OPERAND_USER_ID, "{0}".format(user_id) + +def from_dest_port(dst_port): + return RuleConsts.RULE_TYPE_SIMPLE, RuleConsts.OPERAND_DEST_PORT, "{0}".format(dst_port) + +def from_dest_ip(dst_ip): + return RuleConsts.RULE_TYPE_SIMPLE, RuleConsts.OPERAND_DEST_IP, dst_ip + +def from_dest_host(dst_host): + return RuleConsts.RULE_TYPE_SIMPLE, RuleConsts.OPERAND_DEST_HOST, dst_host + +def from_dest_network(network): + """network is a CIDR or a network alias, for example 192.168.1.0/24""" + return RuleConsts.RULE_TYPE_NETWORK, RuleConsts.OPERAND_DEST_NETWORK, network + +def from_dest_host_wildcard(domain): + """matches a domain and all of its subdomains. + + "yahoo.com" -> ^(|.*\\.)yahoo\\.com$ + """ + escaped = r'\.'.join(domain.split('.')) + return RuleConsts.RULE_TYPE_REGEXP, RuleConsts.OPERAND_DEST_HOST, r'^(|.*\.)%s$' % escaped + +def from_dest_ip_wildcard(prefix): + """matches a range of addresses by prefix. + + "192.168.*" -> 192\\.168\\..* + """ + return RuleConsts.RULE_TYPE_REGEXP, RuleConsts.OPERAND_DEST_IP, \ + "%s" % r'\.'.join(prefix.split('.')).replace("*", ".*") + +def from_appimage_path(process_path): + """appimages are mounted on /tmp/.mount_, which changes on every run. + + Usually appimages add 6 random characters after the prefix, but some of them + do not follow this rule (Eden appimage for example, #1377). + """ + appimage_bin = os.path.basename(process_path) + appimage_path = os.path.dirname(process_path).replace('.', r'\.') + appimage_path = appimage_path[0:len(APPIMAGE_PREFIX)+7] + return RuleConsts.RULE_TYPE_REGEXP, RuleConsts.OPERAND_PROCESS_PATH, \ + r'^{0}[0-9A-Za-z]+\/.*{1}$'.format(appimage_path, appimage_bin) + +def from_snap_path(process_path): + """snap paths contain a revision number, which changes after every update.""" + snap_parts = process_path.split('/') + snap_prefix = snap_parts[1] + app = snap_parts[2] + app_path = r'\/'.join(snap_parts[4:]) + return RuleConsts.RULE_TYPE_REGEXP, RuleConsts.OPERAND_PROCESS_PATH, \ + r'^\/{0}\/{1}\/[0-9]+\/{2}$'.format(snap_prefix, app, app_path) + + +def get_operator(field, value, con): + """returns the (type, operand, data) tuple for the given field. + + value is the already normalized value of the field: no display prefixes and + no leading "*." for the wildcard fields. It's ignored by the fields that are + fully determined by the connection. + """ + if field == FIELD_PROC_PATH: + return from_process_path(con.process_path) + elif field == FIELD_PROC_ARGS: + return from_process_command(con.process_args, con.process_path) + elif field == FIELD_PROC_ID: + return from_process_id(con.process_id) + elif field == FIELD_USER_ID: + return from_user_id(con.user_id) + elif field == FIELD_DST_PORT: + return from_dest_port(con.dst_port) + elif field == FIELD_DST_IP: + return from_dest_ip(con.dst_ip) + elif field == FIELD_DST_HOST: + return from_dest_host(value) + elif field == FIELD_DST_NETWORK: + return from_dest_network(value) + elif field == FIELD_REGEX_HOST: + return from_dest_host_wildcard(value) + elif field == FIELD_REGEX_IP: + return from_dest_ip_wildcard(value) + elif field == FIELD_APPIMAGE: + return from_appimage_path(con.process_path) + elif field == FIELD_SNAP: + return from_snap_path(con.process_path) + + return None, None, None + + +def dest_ip_wildcards(dst_ip): + """progressively wider address prefixes: 192.*, 192.168.*, ...""" + prefixes = [] + parts = dst_ip.split('.') + for i in range(1, len(parts)): + prefixes.append("{0}.*".format('.'.join(parts[:i]))) + return prefixes + +def dest_host_wildcards(dst_host): + """parent domains of a host: for a.b.example.com -> b.example.com, example.com""" + domains = [] + parts = dst_host.split('.')[1:] + for i in range(0, len(parts) - 1): + domains.append('.'.join(parts[i:])) + return domains + +def dest_networks(dst_ip): + """the networks the address belongs to, widest last, plus any matching alias.""" + networks = [] + alias = _get_network_alias(dst_ip) + if alias: + networks.append(alias) + if type(ipaddress.ip_address(dst_ip)) == ipaddress.IPv4Address: + masks = ("/24", "/16", "/8") + else: + masks = ("/64", "/128") + for mask in masks: + networks.append("{0}".format(ipaddress.ip_network(dst_ip + mask, strict=False))) + return networks + + +def candidates(con): + """everything a connection can reasonably be matched on, best guess first. + + Same set the pop-up offers in its "apply to" combo, as a plain list so that + it can be printed in a terminal. Each entry is a dict with a label to show + and the (type, operand, data) to put in the rule. + """ + found = [] + + def add(label, triple): + op_type, operand, data = triple + if data is None or data == "": + return + found.append({"label": label, "type": op_type, "operand": operand, "data": data}) + + if con.process_path != "": + if con.process_path.startswith(APPIMAGE_PREFIX): + add("this appimage, whatever it is mounted on", + from_appimage_path(con.process_path)) + elif con.process_path.startswith(SNAP_PREFIX): + add("this snap, whatever its revision", from_snap_path(con.process_path)) + add("this executable", from_process_path(con.process_path)) + + if len(con.process_args) > 0 and con.process_args[0] != "": + add("this command line", from_process_command(con.process_args, con.process_path)) + + if con.dst_host != "" and con.dst_host != con.dst_ip: + add("this host", from_dest_host(con.dst_host)) + for domain in dest_host_wildcards(con.dst_host): + add("any host under %s" % domain, from_dest_host_wildcard(domain)) + + if con.dst_ip != "": + add("this address", from_dest_ip(con.dst_ip)) + try: + for prefix in dest_ip_wildcards(con.dst_ip): + add("any address under %s" % prefix, from_dest_ip_wildcard(prefix)) + for network in dest_networks(con.dst_ip): + add("the network %s" % network, from_dest_network(network)) + except ValueError: + pass + + if con.dst_port: + add("port %s" % con.dst_port, from_dest_port(con.dst_port)) + if con.user_id is not None and int(con.user_id) >= 0: + add("user %s" % con.user_id, from_user_id(con.user_id)) + if con.process_id is not None and int(con.process_id) > 0: + add("this pid (%s)" % con.process_id, from_process_id(con.process_id)) + + return found + + +def check_regexp(data): + """returns an error string if the pattern won't work on the daemon side. + + The daemon compiles regexps with Go's RE2, which is more restrictive than + Python's re: it has no lookaround and no backreferences. + """ + try: + re.compile(data) + except re.error as e: + return "invalid regular expression: {0}".format(e) + + for token, name in RE2_UNSUPPORTED: + if token in data: + return "the daemon's regexp engine (RE2) does not support {0} ({1})".format(name, token) + if _BACKREF_RE.search(data): + return "the daemon's regexp engine (RE2) does not support backreferences" + + return None From 9d14a77854a8eeb8c46a8f49ad598440fc3bfb7e Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 20:31:12 -0700 Subject: [PATCH 04/24] ui: don't require Qt to collect the tests The tests of the headless client run on machines without PyQt6. Import it from the qapp fixture instead of at module scope, so that collecting them doesn't fail. The GUI tests are unaffected: the fixture is what creates QApplication. Co-Authored-By: Claude Opus 5 --- ui/tests/conftest.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/tests/conftest.py b/ui/tests/conftest.py index 5dd29006b3..f904eefbc2 100644 --- a/ui/tests/conftest.py +++ b/ui/tests/conftest.py @@ -3,10 +3,13 @@ # This file sets up Qt and database before tests run. import pytest -from PyQt6 import QtWidgets from unittest.mock import patch from queue import Queue +# PyQt6 is imported from the fixtures that need it, not here: the tests of +# opensnitch-cli run on machines without Qt, and importing it at this point +# would make collecting them fail. + # Global flag to track initialization _initialized = False @@ -38,6 +41,8 @@ def init_test_environment(): @pytest.fixture(scope="session") def qapp(): """Create QApplication for the entire test session.""" + from PyQt6 import QtWidgets + app = QtWidgets.QApplication.instance() if app is None: app = QtWidgets.QApplication([]) From 50a02d2c4e0f5d3f3fc4f19ca11ea48de30f2a2a Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 20:31:33 -0700 Subject: [PATCH 05/24] cli: add the configuration and the review queue First part of opensnitch-cli, a client for servers with no graphical environment. Nothing under opensnitch/cli/ may import Qt. The configuration is an ini file read with configparser, since the GUI keeps its settings in QSettings. Running with no configuration file at all works: the defaults are the whole contract. Values are validated at start up rather than when they're first used, because a firewall tool that quietly ignores a setting it didn't understand is worse than one that refuses to start. The queue is a SQLite database shared by two processes: the service that records connections, and the review command that decides what to do with them. They talk to each other through the outbox table only, so a decision taken while the service is stopped is applied when it starts again. WAL is what makes sharing the file safe. Durations are validated against what Go's time.ParseDuration accepts, which is not what opensnitch/utils/duration does: days and weeks are not supported by the daemon, and it discards the parsing error, which would leave a rule that never expires. Co-Authored-By: Claude Opus 5 --- ui/opensnitch/cli/__init__.py | 26 +++ ui/opensnitch/cli/config.py | 160 ++++++++++++++ ui/opensnitch/cli/db.py | 379 +++++++++++++++++++++++++++++++++ ui/opensnitch/cli/durations.py | 90 ++++++++ ui/opensnitch/cli/proto.py | 28 +++ 5 files changed, 683 insertions(+) create mode 100644 ui/opensnitch/cli/__init__.py create mode 100644 ui/opensnitch/cli/config.py create mode 100644 ui/opensnitch/cli/db.py create mode 100644 ui/opensnitch/cli/durations.py create mode 100644 ui/opensnitch/cli/proto.py diff --git a/ui/opensnitch/cli/__init__.py b/ui/opensnitch/cli/__init__.py new file mode 100644 index 0000000000..ee23ccc6da --- /dev/null +++ b/ui/opensnitch/cli/__init__.py @@ -0,0 +1,26 @@ +# Copyright (C) 2026 The OpenSnitch Authors +# +# This file is part of OpenSnitch. +# +# OpenSnitch is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenSnitch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenSnitch. If not, see . + +"""Headless client for servers without a graphical environment. + +opensnitch-cli serve answers the daemon's AskRule requests without a human in +the loop, and records every connection it hasn't seen before in a review queue. +opensnitch-cli review goes through that queue afterwards. + +Nothing in this package may import PyQt: it's meant to run on machines where Qt +is not installed. ui/tests/cli/test_no_qt.py enforces it. +""" diff --git a/ui/opensnitch/cli/config.py b/ui/opensnitch/cli/config.py new file mode 100644 index 0000000000..4939d65f75 --- /dev/null +++ b/ui/opensnitch/cli/config.py @@ -0,0 +1,160 @@ +# Copyright (C) 2026 The OpenSnitch Authors +# +# This file is part of OpenSnitch. +# +# OpenSnitch is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenSnitch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenSnitch. If not, see . + +"""Configuration of opensnitch-cli. + +An ini file, read with configparser. The GUI keeps its settings in QSettings, +which needs Qt, so we can't share it. Running without a configuration file at +all is supported: the defaults below are the whole contract. +""" + +import configparser +import os + +from opensnitch.rule_consts import RuleConsts +from opensnitch.cli import durations + +DEFAULT_CONFIG_PATHS = ( + "~/.config/opensnitch/cli.conf", + "/etc/opensnitch/cli.conf", +) + +DEFAULTS = { + "server": { + "address": "unix:///tmp/osui.sock", + "auth_type": "simple", + "tls_ca_cert": "", + "tls_cert": "", + "tls_key": "", + "max_workers": "10", + "max_clients": "0", + "keepalive": "5000", + "keepalive_timeout": "20000", + "max_message_length": "4194304", + }, + "policy": { + "unreviewed_action": "allow", + "unreviewed_duration": "1h", + "default_action": "allow", + "queue_max": "1000", + }, + "db": { + "path": "/var/lib/opensnitch/cli.db", + "retention_days": "30", + }, + "log": { + "level": "info", + "file": "", + "store_alerts": "false", + }, +} + +ACTIONS = (RuleConsts.ACTION_ALLOW, RuleConsts.ACTION_DENY, RuleConsts.ACTION_REJECT) +AUTH_TYPES = ("simple", "tls-simple", "tls-mutual") +LOG_LEVELS = ("debug", "info", "warning", "error") + + +class ConfigError(Exception): + """the configuration file says something we can't act on.""" + + +class Config: + """typed, validated access to the ini file.""" + + def __init__(self, path=None): + self.path = path + self._parser = configparser.ConfigParser() + self._parser.read_dict(DEFAULTS) + + if path is None: + path = self._find() + if path is not None: + if not os.path.isfile(path): + raise ConfigError("configuration file not found: {0}".format(path)) + self._parser.read(path) + self.path = path + + self._validate() + + def _find(self): + for candidate in DEFAULT_CONFIG_PATHS: + candidate = os.path.expanduser(candidate) + if os.path.isfile(candidate): + return candidate + return None + + def _validate(self): + """fail at startup rather than half way through a decision. + + A firewall tool that silently ignores a setting it didn't understand is + worse than one that refuses to start. + """ + self._choice("policy", "unreviewed_action", ACTIONS) + self._choice("policy", "default_action", (RuleConsts.ACTION_ALLOW, RuleConsts.ACTION_DENY)) + self._choice("server", "auth_type", AUTH_TYPES) + self._choice("log", "level", LOG_LEVELS) + + duration = self.get("policy", "unreviewed_duration") + err = durations.validate(duration) + if err is not None: + raise ConfigError("policy.unreviewed_duration: {0}".format(err)) + # "once" rules are dropped by the daemon instead of being stored + # (daemon/rule/loader.go, addUserRule), so it would ask again for every + # single connection, and every ask blocks a packet. + if duration == RuleConsts.DURATION_ONCE: + raise ConfigError( + "policy.unreviewed_duration cannot be '{0}': the daemon does not keep " + "'{0}' rules, so it would ask again for every connection".format( + RuleConsts.DURATION_ONCE)) + + for section, option in (("server", "max_workers"), ("server", "max_clients"), + ("server", "keepalive"), ("server", "keepalive_timeout"), + ("server", "max_message_length"), + ("policy", "queue_max"), ("db", "retention_days")): + self.getint(section, option) + + if self.getint("server", "max_workers") < 3: + # one worker is pinned by the notifications stream of each node, one + # more is taken while asking, and Ping needs one now and then. + raise ConfigError("server.max_workers must be at least 3") + + def _choice(self, section, option, valid): + value = self.get(section, option) + if value not in valid: + raise ConfigError("{0}.{1}: '{2}' is not one of {3}".format( + section, option, value, ", ".join(valid))) + return value + + def get(self, section, option): + return self._parser.get(section, option).strip() + + def getint(self, section, option): + try: + return int(self.get(section, option)) + except ValueError: + raise ConfigError("{0}.{1}: '{2}' is not a number".format( + section, option, self.get(section, option))) + + def getbool(self, section, option): + try: + return self._parser.getboolean(section, option) + except ValueError: + raise ConfigError("{0}.{1}: '{2}' is not a boolean".format( + section, option, self.get(section, option))) + + def db_path(self): + return os.path.expanduser(self.get("db", "path")) diff --git a/ui/opensnitch/cli/db.py b/ui/opensnitch/cli/db.py new file mode 100644 index 0000000000..7873005b02 --- /dev/null +++ b/ui/opensnitch/cli/db.py @@ -0,0 +1,379 @@ +# Copyright (C) 2026 The OpenSnitch Authors +# +# This file is part of OpenSnitch. +# +# OpenSnitch is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenSnitch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenSnitch. If not, see . + +"""Storage of the review queue. + +Two processes use this database at the same time: 'opensnitch-cli serve', which +writes the queue and drains the outbox, and 'opensnitch-cli review', which reads +the queue and writes decisions to the outbox. WAL is what makes that safe. + +The outbox is the only channel between them. review never talks to the daemon: +it queues a notification, and serve sends it on the next tick. That way a +decision taken while the service is stopped is applied when it starts again. + +The GUI's database (opensnitch.database) is built on QtSql, so it isn't reused. +""" + +import json +import os +import sqlite3 +import threading +import time + +SCHEMA_VERSION = 1 + +STATE_PENDING = "pending" +STATE_DECIDED = "decided" +STATE_DROPPED = "dropped" + +OUT_QUEUED = "queued" +OUT_SENT = "sent" +OUT_DONE = "done" +OUT_ERROR = "error" + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS nodes ( + addr TEXT PRIMARY KEY, + hostname TEXT, + version TEXT, + online INTEGER NOT NULL DEFAULT 0, + first_seen INTEGER, + last_seen INTEGER +); + +CREATE TABLE IF NOT EXISTS pending ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node TEXT NOT NULL, + signature TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + hits INTEGER NOT NULL DEFAULT 1, + first_seen INTEGER, + last_seen INTEGER, + provisional_name TEXT, + provisional_action TEXT, + provisional_duration TEXT, + provisional_expires INTEGER, + protocol TEXT, + dst_ip TEXT, + dst_host TEXT, + dst_port INTEGER, + user_id INTEGER, + process_id INTEGER, + process_path TEXT, + process_cwd TEXT, + process_args TEXT, + process_checksums TEXT, + decided_at INTEGER, + decided_rule TEXT, + UNIQUE(node, signature) +); +CREATE INDEX IF NOT EXISTS pending_state_idx ON pending(state, last_seen); + +CREATE TABLE IF NOT EXISTS outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created INTEGER, + node TEXT NOT NULL, + ntf_type INTEGER NOT NULL, + rule_json TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'queued', + attempts INTEGER NOT NULL DEFAULT 0, + sent_at INTEGER, + ntf_id INTEGER, + last_error TEXT, + updated INTEGER, + pending_id INTEGER +); +CREATE INDEX IF NOT EXISTS outbox_state_idx ON outbox(state, id); + +CREATE TABLE IF NOT EXISTS rules ( + node TEXT NOT NULL, + name TEXT NOT NULL, + enabled INTEGER, + action TEXT, + duration TEXT, + op_type TEXT, + op_operand TEXT, + op_data TEXT, + updated INTEGER, + PRIMARY KEY(node, name) +); + +CREATE TABLE IF NOT EXISTS alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node TEXT, + time INTEGER, + type INTEGER, + what INTEGER, + priority INTEGER, + body TEXT +); +""" + + +class Database: + """the review queue and the outbox. + + Every method is safe to call from several threads of the same process, and + the file is safe to share with another process. + """ + + def __init__(self, path): + self.path = path + self._lock = threading.RLock() + + if path != ":memory:": + directory = os.path.dirname(path) + if directory != "" and not os.path.isdir(directory): + os.makedirs(directory, mode=0o700, exist_ok=True) + + # check_same_thread=False: the gRPC worker threads and the outbox thread + # all use this connection, serialized by self._lock. + self._db = sqlite3.connect(path, timeout=10.0, isolation_level=None, + check_same_thread=False) + self._db.row_factory = sqlite3.Row + self._setup() + + def _setup(self): + with self._lock: + if self.path != ":memory:": + # WAL lets review read while serve writes. It's persistent, so + # setting it on every open is harmless. + self._db.execute("PRAGMA journal_mode=WAL") + self._db.execute("PRAGMA synchronous=NORMAL") + self._db.execute("PRAGMA busy_timeout=10000") + self._db.executescript(SCHEMA) + + version = self._db.execute("PRAGMA user_version").fetchone()[0] + if version == 0: + self._db.execute("PRAGMA user_version={0}".format(SCHEMA_VERSION)) + elif version > SCHEMA_VERSION: + raise RuntimeError( + "{0} was created by a newer version of opensnitch-cli " + "(schema {1} > {2})".format(self.path, version, SCHEMA_VERSION)) + + def close(self): + with self._lock: + self._db.close() + + # nodes + + def node_seen(self, addr, hostname, version, online=True): + now = int(time.time()) + with self._lock: + self._db.execute( + "INSERT INTO nodes (addr, hostname, version, online, first_seen, last_seen) " + "VALUES (?, ?, ?, ?, ?, ?) " + "ON CONFLICT(addr) DO UPDATE SET hostname=excluded.hostname, " + "version=excluded.version, online=excluded.online, last_seen=excluded.last_seen", + (addr, hostname, version, 1 if online else 0, now, now)) + + def node_offline(self, addr): + with self._lock: + self._db.execute("UPDATE nodes SET online=0 WHERE addr=?", (addr,)) + + def nodes(self): + with self._lock: + return self._db.execute("SELECT * FROM nodes ORDER BY addr").fetchall() + + # the review queue + + def record_pending(self, node, signature, con, provisional): + """adds the connection to the queue, or counts another attempt. + + provisional is the rule we answered with, so that review can tell the + daemon to forget it once a real rule is in place. + + Returns (row id, True when it's the first time we see this signature). + """ + now = int(time.time()) + expires = None + if provisional.get("expires_in") is not None: + expires = now + int(provisional["expires_in"]) + + with self._lock: + cur = self._db.execute("SELECT id FROM pending WHERE node=? AND signature=?", + (node, signature)) + row = cur.fetchone() + if row is not None: + self._db.execute( + "UPDATE pending SET hits=hits+1, last_seen=?, state=?, " + "provisional_name=?, provisional_action=?, provisional_duration=?, " + "provisional_expires=? WHERE id=?", + (now, STATE_PENDING, provisional.get("name"), provisional.get("action"), + provisional.get("duration"), expires, row["id"])) + return row["id"], False + + cur = self._db.execute( + "INSERT INTO pending (node, signature, state, hits, first_seen, last_seen, " + "provisional_name, provisional_action, provisional_duration, provisional_expires, " + "protocol, dst_ip, dst_host, dst_port, user_id, process_id, process_path, " + "process_cwd, process_args, process_checksums) " + "VALUES (?,?,?,1,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + (node, signature, STATE_PENDING, now, now, + provisional.get("name"), provisional.get("action"), + provisional.get("duration"), expires, + con.protocol, con.dst_ip, con.dst_host, con.dst_port, + con.user_id, con.process_id, con.process_path, con.process_cwd, + json.dumps(list(con.process_args)), + json.dumps(dict(con.process_checksums)))) + return cur.lastrowid, True + + def pending_count(self): + with self._lock: + return self._db.execute("SELECT COUNT(*) FROM pending WHERE state=?", + (STATE_PENDING,)).fetchone()[0] + + def pending(self, node=None, limit=None, state=STATE_PENDING): + query = "SELECT * FROM pending WHERE state=?" + args = [state] + if node is not None: + query += " AND node=?" + args.append(node) + query += " ORDER BY id" + if limit is not None: + query += " LIMIT ?" + args.append(limit) + with self._lock: + return self._db.execute(query, args).fetchall() + + def get_pending(self, entry_id): + with self._lock: + return self._db.execute("SELECT * FROM pending WHERE id=?", (entry_id,)).fetchone() + + def get_pending_by_signature(self, node, signature): + with self._lock: + return self._db.execute("SELECT * FROM pending WHERE node=? AND signature=?", + (node, signature)).fetchone() + + def set_pending_state(self, entry_id, state, rule_json=None): + with self._lock: + self._db.execute( + "UPDATE pending SET state=?, decided_at=?, decided_rule=? WHERE id=?", + (state, int(time.time()), rule_json, entry_id)) + + def expire_provisionals(self): + """clears the provisional rule of entries whose temporary rule has gone. + + The daemon removes the rule by itself; this only keeps the queue honest + about what is currently covered. + """ + now = int(time.time()) + with self._lock: + cur = self._db.execute( + "UPDATE pending SET provisional_name=NULL, provisional_action=NULL, " + "provisional_duration=NULL, provisional_expires=NULL " + "WHERE state=? AND provisional_expires IS NOT NULL AND provisional_expires<=?", + (STATE_PENDING, now)) + return cur.rowcount + + # the outbox + + def queue_notification(self, node, ntf_type, rule_json, pending_id=None): + with self._lock: + cur = self._db.execute( + "INSERT INTO outbox (created, node, ntf_type, rule_json, state, updated, pending_id) " + "VALUES (?,?,?,?,?,?,?)", + (int(time.time()), node, ntf_type, rule_json, OUT_QUEUED, + int(time.time()), pending_id)) + return cur.lastrowid + + def queued_notifications(self, limit=50): + with self._lock: + return self._db.execute( + "SELECT * FROM outbox WHERE state=? ORDER BY id LIMIT ?", + (OUT_QUEUED, limit)).fetchall() + + def mark_sent(self, outbox_id, ntf_id): + with self._lock: + self._db.execute( + "UPDATE outbox SET state=?, sent_at=?, ntf_id=?, attempts=attempts+1, updated=? " + "WHERE id=?", + (OUT_SENT, int(time.time()), ntf_id, int(time.time()), outbox_id)) + + def mark_result(self, ntf_id, ok, error=None): + """records the daemon's answer to a notification we sent.""" + with self._lock: + self._db.execute( + "UPDATE outbox SET state=?, last_error=?, updated=? WHERE ntf_id=? AND state=?", + (OUT_DONE if ok else OUT_ERROR, error, int(time.time()), ntf_id, OUT_SENT)) + + def requeue_sent(self): + """puts unanswered notifications back in the queue. + + Called at start up: anything still marked as sent was in flight when the + service stopped. Re-sending is safe, the daemon replaces rules by name + and deleting a rule that isn't there does nothing. + """ + with self._lock: + cur = self._db.execute("UPDATE outbox SET state=? WHERE state=?", + (OUT_QUEUED, OUT_SENT)) + return cur.rowcount + + def get_outbox(self, outbox_id): + with self._lock: + return self._db.execute("SELECT * FROM outbox WHERE id=?", (outbox_id,)).fetchone() + + def outbox_errors(self): + with self._lock: + return self._db.execute( + "SELECT * FROM outbox WHERE state=? ORDER BY id", (OUT_ERROR,)).fetchall() + + # the daemon's rules, as reported on Subscribe + + def replace_rules(self, node, rules): + with self._lock: + self._db.execute("DELETE FROM rules WHERE node=?", (node,)) + now = int(time.time()) + self._db.executemany( + "INSERT OR REPLACE INTO rules (node, name, enabled, action, duration, " + "op_type, op_operand, op_data, updated) VALUES (?,?,?,?,?,?,?,?,?)", + [(node, r.name, 1 if r.enabled else 0, r.action, r.duration, + r.operator.type, r.operator.operand, r.operator.data, now) for r in rules]) + + def rules(self, node=None): + query = "SELECT * FROM rules" + args = [] + if node is not None: + query += " WHERE node=?" + args.append(node) + query += " ORDER BY node, name" + with self._lock: + return self._db.execute(query, args).fetchall() + + def rule_names(self, node): + with self._lock: + rows = self._db.execute("SELECT name FROM rules WHERE node=?", (node,)).fetchall() + return set([r["name"] for r in rows]) + + def add_alert(self, node, alert): + with self._lock: + self._db.execute( + "INSERT INTO alerts (node, time, type, what, priority, body) VALUES (?,?,?,?,?,?)", + (node, int(time.time()), alert.type, alert.what, alert.priority, str(alert))) + + def purge(self, retention_days): + """drops decided entries and finished notifications after a while.""" + if retention_days <= 0: + return 0 + cutoff = int(time.time()) - (retention_days * 86400) + with self._lock: + cur = self._db.execute( + "DELETE FROM pending WHERE state!=? AND decided_at IS NOT NULL AND decided_at. + +"""Rule durations, as the daemon understands them. + +Anything that is not one of the three keywords is parsed by the daemon with +Go's time.ParseDuration (daemon/rule/loader.go, scheduleTemporaryRule), which +accepts ns, us, ms, s, m and h, and *not* days or weeks. A duration the daemon +can't parse doesn't fail loudly: the error is discarded and the rule ends up +never expiring, so validate before sending. + +opensnitch.utils.duration is the GUI's equivalent, but it needs Qt (it lives +under opensnitch.utils) and it treats "1d" as 60 hours, so it isn't reused here. +""" + +import re + +from opensnitch.rule_consts import RuleConsts + +# the durations the pop-up offers, in the order it offers them +COMMON = ( + RuleConsts.DURATION_30s, + RuleConsts.DURATION_5m, + RuleConsts.DURATION_15m, + RuleConsts.DURATION_30m, + RuleConsts.DURATION_1h, + RuleConsts.DURATION_12h, + RuleConsts.DURATION_UNTIL_RESTART, + RuleConsts.DURATION_ALWAYS, +) + +KEYWORDS = ( + RuleConsts.DURATION_ONCE, + RuleConsts.DURATION_UNTIL_RESTART, + RuleConsts.DURATION_ALWAYS, +) + +_UNITS = {"ns": 1e-9, "us": 1e-6, "µs": 1e-6, "ms": 1e-3, "s": 1, "m": 60, "h": 3600} +# same grammar as Go's time.ParseDuration, without the sign +_GO_DURATION = re.compile(r'^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$') +_GO_PART = re.compile(r'([0-9]+(?:\.[0-9]+)?)(ns|us|µs|ms|s|m|h)') + + +def validate(duration): + """returns an error string, or None when the daemon will understand it.""" + if duration in KEYWORDS: + return None + if duration == "": + return "empty duration" + if not _GO_DURATION.match(duration): + return ("'{0}' is not a valid duration: use one of {1}, or a value like " + "30s, 5m, 1h30m (days and weeks are not supported by the daemon)".format( + duration, ", ".join(KEYWORDS))) + return None + + +def to_seconds(duration): + """seconds a temporary rule will live for, or None if it isn't time based.""" + if duration in KEYWORDS: + return None + if validate(duration) is not None: + return None + + total = 0 + for value, unit in _GO_PART.findall(duration): + total += float(value) * _UNITS[unit] + return total + + +def is_temporary(duration): + """whether the daemon will schedule the rule for removal. + + Mirrors daemon/rule/loader.go isTemporary(): "once", "until restart" and + "always" are not scheduled. + """ + return duration not in KEYWORDS and validate(duration) is None diff --git a/ui/opensnitch/cli/proto.py b/ui/opensnitch/cli/proto.py new file mode 100644 index 0000000000..5cb388a8fe --- /dev/null +++ b/ui/opensnitch/cli/proto.py @@ -0,0 +1,28 @@ +# Copyright (C) 2026 The OpenSnitch Authors +# +# This file is part of OpenSnitch. +# +# OpenSnitch is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenSnitch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenSnitch. If not, see . + +"""The protobuffers, picked according to the installed protobuf version. + +opensnitch.proto.import_() chooses between the current protobuffers and the ones +built for protobuf < 3.20. Importing it from a single place keeps that choice +consistent across the package, and keeps the 'import grpc' cost out of the +commands that don't talk to a daemon. +""" + +from opensnitch import proto + +ui_pb2, ui_pb2_grpc = proto.import_() From 196bd8043f7449eec38134c7be0d4bafee31ba29 Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 20:31:33 -0700 Subject: [PATCH 06/24] cli: add the service and the policy for unreviewed connections The daemon holds the packet in a netfilter queue while it waits for an answer to AskRule, and gives up after two minutes, so the service can't wait for a person to decide. It answers straight away with a temporary rule scoped to the process and the destination it was asked about, and records the connection for review. The decision taken later governs every connection after that. The temporary rule is what stops the daemon asking again for every packet, and it is short lived by default (allow, 1h) so that a connection nobody reviews comes back to the queue instead of being allowed for good. Set policy.unreviewed_action to deny for a machine that should block anything that has not been approved. Rules are checked before they're sent, because the daemon reports a rule it cannot compile by answering the notification with an error, which is easy to miss: the regexps are screened for the lookarounds and backreferences that Go's RE2 rejects, network operands are checked against the operand the daemon allows, and "once" is refused because a rule with that duration sent over the notifications channel is never scheduled for removal. The gRPC set up mirrors bin/opensnitch-ui, so a daemon configured for either connects to the other unchanged, including the TLS options. Co-Authored-By: Claude Opus 5 --- ui/opensnitch/cli/policy.py | 171 +++++++++++++++++++++++++ ui/opensnitch/cli/rules.py | 194 +++++++++++++++++++++++++++++ ui/opensnitch/cli/server.py | 233 +++++++++++++++++++++++++++++++++++ ui/opensnitch/cli/service.py | 226 +++++++++++++++++++++++++++++++++ 4 files changed, 824 insertions(+) create mode 100644 ui/opensnitch/cli/policy.py create mode 100644 ui/opensnitch/cli/rules.py create mode 100644 ui/opensnitch/cli/server.py create mode 100644 ui/opensnitch/cli/service.py diff --git a/ui/opensnitch/cli/policy.py b/ui/opensnitch/cli/policy.py new file mode 100644 index 0000000000..17a4b6dde5 --- /dev/null +++ b/ui/opensnitch/cli/policy.py @@ -0,0 +1,171 @@ +# Copyright (C) 2026 The OpenSnitch Authors +# +# This file is part of OpenSnitch. +# +# OpenSnitch is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenSnitch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenSnitch. If not, see . + +"""What to answer when nobody has reviewed a connection yet. + +The daemon holds the packet in a netfilter queue while it waits for our answer, +and gives up after two minutes (daemon/ui/client.go Ask). So we can't wait for +a person: we answer straight away with a temporary rule and put the connection +in the review queue, and the decision taken later governs everything after that. + +The temporary rule is what stops the daemon asking again for every packet. It +is deliberately short lived, so that a connection nobody ever reviews comes back +to the queue instead of being allowed for good. +""" + +import hashlib +import logging + +from opensnitch.rule_consts import RuleConsts +from opensnitch.cli import durations, rules + +PROVISIONAL_PREFIX = "cli-auto-" + +logger = logging.getLogger(__name__) + + +def signature(node, con): + """what makes two connections "the same thing" for review purposes. + + The process and where it is going, not how it got there: the pid, the source + port and the source address change on every connection, and a host that + resolves to a different address each time (any CDN) would otherwise fill the + queue with duplicates. + """ + parts = ( + node, + con.process_path, + " ".join(con.process_args), + con.dst_host if con.dst_host != "" else con.dst_ip, + str(con.dst_port), + con.protocol, + str(con.user_id), + ) + return hashlib.sha256("|".join(parts).encode("utf-8", "replace")).hexdigest()[:16] + + +def provisional_name(sig): + return "%s%s" % (PROVISIONAL_PREFIX, sig[:12]) + + +def provisional_operators(con): + """the narrowest match that still covers the connection we were asked about. + + Matching only on the executable would let it reach anywhere for as long as + the rule lives, so the destination is part of it too. Any other destination + the same program tries gets its own queue entry, which is what makes the + queue a useful list of "what does this machine talk to". + """ + ops = [] + + if con.process_path != "": + ops.append(rules.new_operator(*operand_process_path(con))) + + if con.dst_host != "" and con.dst_host != con.dst_ip: + ops.append(rules.new_operator(RuleConsts.RULE_TYPE_SIMPLE, + RuleConsts.OPERAND_DEST_HOST, con.dst_host)) + elif con.dst_ip != "": + ops.append(rules.new_operator(RuleConsts.RULE_TYPE_SIMPLE, + RuleConsts.OPERAND_DEST_IP, con.dst_ip)) + + if con.dst_port: + ops.append(rules.new_operator(RuleConsts.RULE_TYPE_SIMPLE, + RuleConsts.OPERAND_DEST_PORT, str(con.dst_port))) + + return ops + + +def operand_process_path(con): + return (RuleConsts.RULE_TYPE_SIMPLE, RuleConsts.OPERAND_PROCESS_PATH, con.process_path) + + +def build_provisional(con, sig, action, duration): + """the rule we hand back to the daemon for a connection nobody has reviewed.""" + ops = provisional_operators(con) + if len(ops) == 0: + # Nothing identifies this connection: no executable, no destination. + # Rather than send a rule that matches everything, let the daemon apply + # its own default action by sending nothing back. + return None + + name = provisional_name(sig) + description = "queued for review by opensnitch-cli" + return rules.build_rule(name, action, duration, ops, description=description) + + +class Policy: + """answers AskRule and keeps the review queue up to date.""" + + def __init__(self, db, config): + self._db = db + self._action = config.get("policy", "unreviewed_action") + self._duration = config.get("policy", "unreviewed_duration") + self._queue_max = config.getint("policy", "queue_max") + self._dropped = 0 + + @property + def action(self): + return self._action + + @property + def duration(self): + return self._duration + + @property + def dropped(self): + return self._dropped + + def on_ask(self, node, con): + """returns the rule to answer the daemon with, or None for its default. + + Never raises: an exception here becomes a gRPC error, the daemon logs a + warning and applies its default action to a packet it is holding. + """ + try: + sig = signature(node, con) + rule = build_provisional(con, sig, self._action, self._duration) + if rule is None: + logger.warning("connection with no process and no destination, " + "letting the daemon decide: %s", con) + return None + + expires_in = durations.to_seconds(self._duration) + provisional = { + "name": rule.name, + "action": rule.action, + "duration": rule.duration, + "expires_in": expires_in, + } + + # A full queue must never stop us answering: the packet is waiting. + # Existing entries still count new attempts, only new signatures are + # refused, and the count is reported by 'opensnitch-cli status'. + if self._db.pending_count() >= self._queue_max: + existing = self._db.get_pending_by_signature(node, sig) + if existing is None: + self._dropped += 1 + if self._dropped == 1 or self._dropped % 100 == 0: + logger.warning("review queue is full (%d entries), not recording " + "new connections (%d so far). Review the queue or " + "raise policy.queue_max", self._queue_max, self._dropped) + return rule + + self._db.record_pending(node, sig, con, provisional) + return rule + except Exception as e: + logger.error("error handling AskRule, letting the daemon decide: %s", repr(e)) + return None diff --git a/ui/opensnitch/cli/rules.py b/ui/opensnitch/cli/rules.py new file mode 100644 index 0000000000..12e8d95c8c --- /dev/null +++ b/ui/opensnitch/cli/rules.py @@ -0,0 +1,194 @@ +# Copyright (C) 2026 The OpenSnitch Authors +# +# This file is part of OpenSnitch. +# +# OpenSnitch is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenSnitch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenSnitch. If not, see . + +"""Building and checking the rules we send to the daemon. + +The daemon rejects a rule it can't compile by answering the notification with +an error, which is easy to miss. Everything here exists so that we find out +before sending instead of afterwards. The checks mirror +daemon/rule/operator.go Compile() and daemon/rule/rule.go Deserialize(). +""" + +import time + +from slugify import slugify + +from opensnitch import operands +from opensnitch.rule_consts import RuleConsts +from opensnitch.cli import durations + +# rule.Deserialize() refuses a rule without an operator, and the daemon then +# falls back to its default action. +LIST_TYPE = RuleConsts.RULE_TYPE_LIST + + +def new_operator(op_type, operand, data, sensitive=False): + from opensnitch.cli.proto import ui_pb2 + + return ui_pb2.Operator(type=op_type, operand=operand, data=data, sensitive=sensitive) + + +def build_rule(name, action, duration, ops, precedence=False, description="", enabled=True, + nolog=False): + """assembles a rule out of one or more operators. + + With more than one operator the daemon expects a rule of type "list", whose + own data is empty and whose operands live in operator.list. That's what the + pop-up does in dialogs/prompt/dialog.py when more than one field is ticked. + """ + from opensnitch.cli.proto import ui_pb2 + + rule = ui_pb2.Rule(name=name) + rule.enabled = enabled + rule.precedence = precedence + rule.nolog = nolog + rule.action = action + rule.duration = duration + rule.description = description + rule.created = int(time.time()) + + if len(ops) == 1: + rule.operator.type = ops[0].type + rule.operator.operand = ops[0].operand + rule.operator.data = ops[0].data + rule.operator.sensitive = ops[0].sensitive + else: + rule.operator.type = LIST_TYPE + rule.operator.operand = LIST_TYPE + # the daemon clears this field for list rules anyway (rule.go + # Deserialize), the operands are read from operator.list + rule.operator.data = "" + for op in ops: + rule.operator.list.append(op) + + return rule + + +def rule_name(action, duration, is_list, data): + """same naming the pop-up uses, see dialogs/prompt/utils.py get_rule_name""" + name = slugify("%s %s" % (action, duration)) + name = "%s-%s" % (name, "list" if is_list else "simple") + return slugify("%s %s" % (name, data))[:128] + + +def unique_name(name, taken): + """avoids a name the node already uses. + + The daemon would otherwise rename our rule itself (loader.go setUniqueName), + and we'd no longer be able to delete it by name. + """ + if name not in taken: + return name + idx = 2 + while "%s-%d" % (name, idx) in taken: + idx += 1 + return "%s-%d" % (name, idx) + + +def validate_operator(op): + """returns an error string, or None when the daemon will accept it.""" + if op.type not in RuleConsts.RulesTypes: + return "unknown rule type '{0}', expected one of {1}".format( + op.type, ", ".join(RuleConsts.RulesTypes)) + + # Only simple, regexp and list operators are allowed to carry no data: + # matching an empty string is meaningful for them. See operator.go Compile() + if op.data == "" and op.type not in (RuleConsts.RULE_TYPE_SIMPLE, + RuleConsts.RULE_TYPE_REGEXP, + RuleConsts.RULE_TYPE_LIST): + return "an operand of type '{0}' cannot have empty data".format(op.type) + + if op.type == RuleConsts.RULE_TYPE_NETWORK and op.operand != RuleConsts.OPERAND_DEST_NETWORK: + return "type '{0}' is only allowed with the operand '{1}', not '{2}'".format( + RuleConsts.RULE_TYPE_NETWORK, RuleConsts.OPERAND_DEST_NETWORK, op.operand) + + if op.type == RuleConsts.RULE_TYPE_REGEXP: + err = operands.check_regexp(op.data) + if err is not None: + return err + + return None + + +def validate_rule(rule): + """returns an error string, or None when the daemon will accept it.""" + if rule.name == "": + return "the rule needs a name" + + if rule.action not in (RuleConsts.ACTION_ALLOW, RuleConsts.ACTION_DENY, + RuleConsts.ACTION_REJECT): + return "unknown action '{0}', expected allow, deny or reject".format(rule.action) + + err = durations.validate(rule.duration) + if err is not None: + return err + # The daemon only skips storing "once" rules on the ask path. One that + # arrives over the notifications channel is stored and never scheduled for + # removal (loader.go isTemporary), so it would live until the daemon exits. + if rule.duration == RuleConsts.DURATION_ONCE: + return ("'{0}' cannot be used here: a rule sent to the daemon with this duration " + "is kept until the daemon restarts. Use 'until restart' if that's what " + "you meant".format(RuleConsts.DURATION_ONCE)) + + if rule.operator.type == LIST_TYPE: + if len(rule.operator.list) == 0: + return "a list rule needs at least one operand" + for op in rule.operator.list: + err = validate_operator(op) + if err is not None: + return err + return None + + return validate_operator(rule.operator) + + +def case_warning(op): + """warns about a regexp that can never match. + + The daemon lowercases the pattern of a non case sensitive regexp before + compiling it (operator.go Compile), so an upper case letter in the pattern + silently stops it from ever matching. + """ + if op.type != RuleConsts.RULE_TYPE_REGEXP or op.sensitive: + return None + if op.data.lower() == op.data: + return None + return ("the pattern contains upper case letters but the operand is not case " + "sensitive: the daemon lowercases it, so it would never match. Make it " + "case sensitive or write it in lower case") + + +def describe_operator(op): + """one line description of an operand, for the review screens.""" + if op.type == RuleConsts.RULE_TYPE_SIMPLE: + return "%s is %s" % (op.operand, op.data) + if op.type == RuleConsts.RULE_TYPE_REGEXP: + return "%s matches %s" % (op.operand, op.data) + if op.type == RuleConsts.RULE_TYPE_NETWORK: + return "%s in %s" % (op.operand, op.data) + return "%s %s %s" % (op.type, op.operand, op.data) + + +def describe_rule(rule): + """multi line description of a rule, for the review screens.""" + lines = ["%s %s" % (rule.action, rule.duration)] + if rule.operator.type == LIST_TYPE: + for op in rule.operator.list: + lines.append(" %s" % describe_operator(op)) + else: + lines.append(" %s" % describe_operator(rule.operator)) + return "\n".join(lines) diff --git a/ui/opensnitch/cli/server.py b/ui/opensnitch/cli/server.py new file mode 100644 index 0000000000..b8abebf5c1 --- /dev/null +++ b/ui/opensnitch/cli/server.py @@ -0,0 +1,233 @@ +# Copyright (C) 2026 The OpenSnitch Authors +# +# This file is part of OpenSnitch. +# +# OpenSnitch is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenSnitch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenSnitch. If not, see . + +"""Starting and stopping the service. + +Mirrors what bin/opensnitch-ui does to set up its gRPC server, so that a daemon +configured for either of them connects to the other without changes. +""" + +import logging +import os +import signal +import threading +import time +from concurrent import futures + +import grpc +from google.protobuf import json_format + +from opensnitch import auth +from opensnitch.cli.proto import ui_pb2, ui_pb2_grpc +from opensnitch.cli import db as dbmod +from opensnitch.cli.policy import Policy +from opensnitch.cli.service import Service + +logger = logging.getLogger(__name__) + +OUTBOX_INTERVAL = 1.0 +PURGE_INTERVAL = 3600.0 + + +def normalize_address(address): + """grpc's python bindings don't take the abstract socket syntax the Go side uses.""" + if address.startswith("unix:@"): + return "unix-abstract:{0}".format(address.split("@", 1)[1]) + return address + + +def unix_socket_path(address): + if address.startswith("unix://"): + return address[len("unix://"):] + if address.startswith("unix:") and not address.startswith("unix:@"): + return address[len("unix:"):] + return None + + +class Server: + def __init__(self, config, database=None): + self._config = config + self._db = database if database is not None else dbmod.Database(config.db_path()) + self._policy = Policy(self._db, config) + self._service = Service(self._db, self._policy, config) + self._server = None + self._exit = threading.Event() + self._threads = [] + self._ntf_seq = 0 + + @property + def db(self): + return self._db + + @property + def service(self): + return self._service + + def _options(self): + maxmsg = self._config.getint("server", "max_message_length") + options = [ + # https://github.com/grpc/grpc/blob/master/doc/keepalive.md + ('grpc.keepalive_time_ms', self._config.getint("server", "keepalive")), + ('grpc.keepalive_timeout_ms', self._config.getint("server", "keepalive_timeout")), + ('grpc.keepalive_permit_without_calls', True), + ('grpc.max_send_message_length', maxmsg), + ('grpc.max_receive_message_length', maxmsg), + ] + max_clients = self._config.getint("server", "max_clients") + if max_clients > 0: + options.append(('grpc.max_allowed_incoming_connections', max_clients)) + return tuple(options) + + def start(self): + address = normalize_address(self._config.get("server", "address")) + sock_path = unix_socket_path(address) + if sock_path is not None: + directory = os.path.dirname(sock_path) + if directory != "" and not os.path.isdir(directory): + os.makedirs(directory, mode=0o700, exist_ok=True) + + # a worker is taken for as long as a node's notifications stream is + # open, one more while a connection is being asked about, and Ping needs + # one now and then: roughly three per node. + workers = self._config.getint("server", "max_workers") + self._server = grpc.server(futures.ThreadPoolExecutor(max_workers=workers), + options=self._options()) + ui_pb2_grpc.add_UIServicer_to_server(self._service, self._server) + + auth_type = self._config.get("server", "auth_type") + if auth_type in (auth.Simple, ""): + port = self._server.add_insecure_port(address) + else: + creds = auth.get_tls_credentials(self._config.get("server", "tls_ca_cert"), + self._config.get("server", "tls_cert"), + self._config.get("server", "tls_key")) + if creds is None: + raise RuntimeError("invalid TLS credentials, check server.tls_cert and " + "server.tls_key") + port = self._server.add_secure_port(address, creds) + + # grpc reports a failure to bind by returning 0, it doesn't raise. The + # usual reason is the graphical interface already listening there. + if port == 0: + raise RuntimeError( + "could not listen on {0}. Is opensnitch-ui or another opensnitch-cli " + "already using it?".format(address)) + + self._server.start() + + if sock_path is not None: + os.chmod(sock_path, 0o640) + + # anything still marked as sent was in flight when we stopped + requeued = self._db.requeue_sent() + if requeued: + logger.info("re-queued %d notifications that were in flight", requeued) + + self._start_thread(self._outbox_loop, "outbox") + self._start_thread(self._housekeeping_loop, "housekeeping") + + logger.info("listening on %s (auth: %s)", address, auth_type) + logger.info("connections nobody has reviewed yet: %s for %s", + self._policy.action, self._policy.duration) + if self._policy.action == "allow": + logger.warning("unreviewed connections are ALLOWED. Set policy.unreviewed_action " + "to deny if this machine should block them instead") + return port + + def _start_thread(self, target, name): + thread = threading.Thread(target=target, name=name, daemon=True) + thread.start() + self._threads.append(thread) + + def stop(self, grace=3): + self._exit.set() + self._service.shutdown() + if self._server is not None: + self._server.stop(grace).wait() + for thread in self._threads: + thread.join(timeout=2) + self._db.close() + logger.info("stopped") + + def serve_forever(self): + self.start() + + def on_signal(signum, frame): + logger.info("got signal %d, stopping", signum) + self._exit.set() + + signal.signal(signal.SIGINT, on_signal) + signal.signal(signal.SIGTERM, on_signal) + + while not self._exit.is_set(): + self._exit.wait(1) + self.stop() + + # the outbox + + def _next_notification_id(self): + """unique and increasing, the daemon echoes it back in its reply.""" + self._ntf_seq += 1 + return (int(time.time()) * 1000) + (self._ntf_seq % 1000) + + def drain_outbox(self): + """sends the decisions taken by 'opensnitch-cli review'. + + Rows for a node that isn't connected stay queued, so a decision taken + while the daemon is down is applied when it comes back. + """ + sent = 0 + for row in self._db.queued_notifications(): + node = self._service.get_node(row["node"]) + if node is None or node.stop.is_set(): + continue + + rule = ui_pb2.Rule() + json_format.Parse(row["rule_json"], rule) + + ntf_id = self._next_notification_id() + notification = ui_pb2.Notification(id=ntf_id, type=row["ntf_type"], rules=[rule]) + self._db.mark_sent(row["id"], ntf_id) + node.queue.put(notification) + sent += 1 + logger.info("sent %s for rule '%s' to %s", + ui_pb2.Action.Name(row["ntf_type"]), rule.name, row["node"]) + return sent + + def _outbox_loop(self): + while not self._exit.is_set(): + try: + self.drain_outbox() + except Exception as e: + logger.error("error draining the outbox: %s", repr(e)) + self._exit.wait(OUTBOX_INTERVAL) + + def _housekeeping_loop(self): + retention = self._config.getint("db", "retention_days") + last_purge = 0 + while not self._exit.is_set(): + try: + self._db.expire_provisionals() + if time.time() - last_purge > PURGE_INTERVAL: + removed = self._db.purge(retention) + if removed: + logger.info("removed %d reviewed entries older than %d days", + removed, retention) + last_purge = time.time() + except Exception as e: + logger.error("error in housekeeping: %s", repr(e)) + self._exit.wait(OUTBOX_INTERVAL) diff --git a/ui/opensnitch/cli/service.py b/ui/opensnitch/cli/service.py new file mode 100644 index 0000000000..04e8ef9c35 --- /dev/null +++ b/ui/opensnitch/cli/service.py @@ -0,0 +1,226 @@ +# Copyright (C) 2026 The OpenSnitch Authors +# +# This file is part of OpenSnitch. +# +# OpenSnitch is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenSnitch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenSnitch. If not, see . + +"""The service the daemon connects to. + +Note the direction: the daemon is the gRPC client and dials us, the same way it +dials the graphical interface (opensnitch/service.py). Only one of the two can +own a given socket, so a machine runs either the GUI or this, not both. +""" + +import copy +import json +import logging +import queue +import threading +import time + +from opensnitch.cli.proto import ui_pb2, ui_pb2_grpc + +logger = logging.getLogger(__name__) + +# put on a node's queue to close its notifications stream. The daemon ends the +# stream for any notification type <= NONE (daemon/ui/notifications.go). +CLOSE_STREAM = -1 + + +class Node: + def __init__(self, addr, peer): + self.addr = addr + self.peer = peer + self.queue = queue.Queue() + self.stop = threading.Event() + self.hostname = "" + self.version = "" + self.last_seen = time.time() + + +class Service(ui_pb2_grpc.UIServicer): + """implements the five calls the daemon makes.""" + + def __init__(self, db, policy, config): + self._db = db + self._policy = policy + self._config = config + self._default_action = config.get("policy", "default_action") + self._store_alerts = config.getbool("log", "store_alerts") + + self._nodes = {} + self._lock = threading.RLock() + self._exit = threading.Event() + # notification id -> outbox row, filled in by the outbox thread + self._sent = {} + + # node bookkeeping + + def peer_addr(self, peer): + """the key we store a node under. + + Same shape the GUI uses (opensnitch/nodes.py get_addr): "proto:address", + with a placeholder for unix sockets, whose peer has no address. + """ + proto, _, addr = peer.partition(":") + if proto.startswith("unix"): + return "%s:%s" % (proto, addr if addr != "" else "/local") + return "%s:%s" % (proto, addr) + + def get_node(self, addr): + with self._lock: + return self._nodes.get(addr) + + def nodes(self): + with self._lock: + return list(self._nodes.values()) + + def shutdown(self): + """asks every daemon to close its notifications stream.""" + self._exit.set() + for node in self.nodes(): + node.stop.set() + node.queue.put(ui_pb2.Notification(id=0, type=CLOSE_STREAM)) + + # the RPCs + + def Ping(self, request, context): + """heartbeat, once a second per node, with a one second deadline. + + Keep it cheap: no database write on this path. The statistics the daemon + sends are ignored for now, the review queue is fed by AskRule. + """ + addr = self.peer_addr(context.peer()) + node = self.get_node(addr) + if node is not None: + node.last_seen = time.time() + # the daemon checks that the id it sent comes back + return ui_pb2.PingReply(id=request.id) + + def Subscribe(self, node_config, context): + """a daemon introduces itself. + + Registering must finish before Notifications arrives, otherwise the + stream is refused. We have no event loop, so unlike the GUI we simply do + it before returning. + """ + peer = context.peer() + addr = self.peer_addr(peer) + + with self._lock: + node = self._nodes.get(addr) + if node is None or node.peer != peer: + node = Node(addr, peer) + self._nodes[addr] = node + node.hostname = node_config.name + node.version = node_config.version + + self._db.node_seen(addr, node_config.name, node_config.version, online=True) + self._db.replace_rules(addr, node_config.rules) + + logger.info("node connected: %s (%s), %d rules", addr, node_config.name, + len(node_config.rules)) + return self._with_default_action(node_config) + + def _with_default_action(self, node_config): + """tells the daemon what to do when we can't answer. + + The daemon uses this for connections that arrive while it's already + waiting for an answer to another one, and when a call to us fails. It + only applies while we're connected and is not written to disk. Same as + the GUI's _overwrite_nodes_config(). + """ + new_config = copy.deepcopy(node_config) + try: + config = json.loads(new_config.config) + config['DefaultAction'] = self._default_action + new_config.config = json.dumps(config) + except Exception as e: + logger.warning("could not read the node's configuration, leaving it alone: %s", + repr(e)) + return node_config + return new_config + + def AskRule(self, request, context): + """a connection the daemon has no rule for. + + Answered immediately, see opensnitch/cli/policy.py. Returning None makes + the daemon apply its default action. + """ + addr = self.peer_addr(context.peer()) + rule = self._policy.on_ask(addr, request) + if rule is None: + return None + + logger.info("%s: %s -> %s:%d, answering %s %s", addr, + request.process_path or "?", + request.dst_host or request.dst_ip, request.dst_port, + rule.action, rule.duration) + return rule + + def Notifications(self, node_iter, context): + """the channel we send rule changes on, and the daemon replies on.""" + peer = context.peer() + addr = self.peer_addr(peer) + node = self.get_node(addr) + if node is None: + logger.warning("notifications from an unknown node: %s", addr) + return + + def on_closed(): + logger.info("node disconnected: %s", addr) + node.stop.set() + self._db.node_offline(addr) + + context.add_callback(on_closed) + + reader = threading.Thread(target=self._read_replies, args=(node, node_iter), + name="replies-%s" % addr, daemon=True) + reader.start() + + while not node.stop.is_set() and not self._exit.is_set(): + try: + notification = node.queue.get(timeout=1) + except queue.Empty: + continue + if notification.type == CLOSE_STREAM: + break + yield notification + + def _read_replies(self, node, node_iter): + """records what the daemon made of the notifications we sent.""" + try: + for reply in node_iter: + # the daemon opens the stream with an id of 0, before we've sent + # anything (daemon/ui/notifications.go listenForNotifications) + if reply.id == 0: + continue + ok = reply.code == ui_pb2.OK + if not ok: + logger.error("node %s rejected notification %d: %s", + node.addr, reply.id, reply.data) + self._db.mark_result(reply.id, ok, None if ok else reply.data) + except Exception as e: + logger.debug("notifications stream of %s closed: %s", node.addr, repr(e)) + finally: + node.stop.set() + + def PostAlert(self, alert, context): + addr = self.peer_addr(context.peer()) + if self._store_alerts: + try: + self._db.add_alert(addr, alert) + except Exception as e: + logger.warning("could not store alert: %s", repr(e)) + return ui_pb2.MsgResponse(id=0) From d92f951839ab088b7a4a833c0bcb36fa279300a7 Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 20:31:33 -0700 Subject: [PATCH 07/24] cli: add the interactive review Walks the queue one connection at a time, in the style of git add -p: single key answers, and an empty answer does nothing rather than picking a default. The editor offers the same choices the pop-up does, built from the shared operand module: the executable, the whole command line, the destination host or address, the host and address wildcards, the networks it belongs to, the port, the user, and anything else typed by hand. Extra conditions turn the rule into a list rule, the same way the pop-up's checkboxes do. Applying a decision queues two notifications: the temporary rule the service answered with is deleted first so it stops covering the connection, then the reviewed rule is installed. Reading and writing go through arguments rather than input() and print(), so the loop can be driven by a test. Co-Authored-By: Claude Opus 5 --- ui/opensnitch/cli/review.py | 427 ++++++++++++++++++++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 ui/opensnitch/cli/review.py diff --git a/ui/opensnitch/cli/review.py b/ui/opensnitch/cli/review.py new file mode 100644 index 0000000000..2f9e4e617c --- /dev/null +++ b/ui/opensnitch/cli/review.py @@ -0,0 +1,427 @@ +# Copyright (C) 2026 The OpenSnitch Authors +# +# This file is part of OpenSnitch. +# +# OpenSnitch is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenSnitch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenSnitch. If not, see . + +"""Going through the review queue, one connection at a time. + +Reading and writing go through the read/write arguments rather than input() and +print() directly, so that the whole loop can be driven by a test. +""" + +import json +import time + +from opensnitch import operands +from opensnitch.rule_consts import RuleConsts +from opensnitch.cli import durations, rules + +SEPARATOR = "─" * 62 + +HELP = """ + y apply the rule as shown + n same match, but deny instead + r same match, but reject (deny silently drops, reject answers) + e edit the rule before applying it + s skip, leave it in the queue + d drop it from the queue, without creating a rule + i show everything known about the connection + q quit +""" + + +def _fmt_age(timestamp): + if not timestamp: + return "?" + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) + + +def _args_of(entry): + try: + return json.loads(entry["process_args"] or "[]") + except ValueError: + return [] + + +def entry_connection(entry): + """rebuilds the Connection we were asked about, out of its queue row.""" + from opensnitch.cli.proto import ui_pb2 + + con = ui_pb2.Connection() + con.protocol = entry["protocol"] or "" + con.dst_ip = entry["dst_ip"] or "" + con.dst_host = entry["dst_host"] or "" + con.dst_port = entry["dst_port"] or 0 + con.user_id = entry["user_id"] if entry["user_id"] is not None else 0 + con.process_id = entry["process_id"] if entry["process_id"] is not None else 0 + con.process_path = entry["process_path"] or "" + con.process_cwd = entry["process_cwd"] or "" + for arg in _args_of(entry): + con.process_args.append(arg) + try: + for key, value in json.loads(entry["process_checksums"] or "{}").items(): + con.process_checksums[key] = value + except ValueError: + pass + return con + + +class Decision: + """the rule about to be created for a queue entry.""" + + def __init__(self, entry, con, default_action, default_duration, taken_names): + self.entry = entry + self.con = con + self.action = default_action + self.duration = default_duration + self.precedence = False + self.description = "" + self._taken = taken_names + self._custom_name = None + + self.candidates = operands.candidates(con) + # the executable is the sensible default, same as the pop-up's + self.selected = self.candidates[0] if len(self.candidates) > 0 else None + self.extra = [] + + def operators(self): + ops = [] + if self.selected is not None: + ops.append(rules.new_operator(self.selected["type"], self.selected["operand"], + self.selected["data"])) + for cand in self.extra: + ops.append(rules.new_operator(cand["type"], cand["operand"], cand["data"])) + return ops + + @property + def name(self): + if self._custom_name is not None: + return self._custom_name + ops = self.operators() + if len(ops) == 0: + return "" + data = ops[0].data + return rules.unique_name( + rules.rule_name(self.action, self.duration, len(ops) > 1, data), self._taken) + + @name.setter + def name(self, value): + self._custom_name = value + + def build(self): + ops = self.operators() + if len(ops) == 0: + return None + return rules.build_rule(self.name, self.action, self.duration, ops, + precedence=self.precedence, description=self.description) + + def validate(self): + rule = self.build() + if rule is None: + return "no match selected" + return rules.validate_rule(rule) + + def warnings(self): + found = [] + for op in self.operators(): + warning = rules.case_warning(op) + if warning is not None: + found.append(warning) + if op.type == RuleConsts.RULE_TYPE_REGEXP and op.data in (".*", "^.*$"): + found.append("'%s' matches everything" % op.data) + return found + + +def render_entry(entry, con, index, total, write): + write(SEPARATOR) + write("[%d/%d] %s seen %sx first %s last %s" % ( + index, total, entry["node"], entry["hits"], + _fmt_age(entry["first_seen"]), _fmt_age(entry["last_seen"]))) + + process = entry["process_path"] or "(unknown process)" + write(" %s pid %s uid %s" % (process, entry["process_id"], entry["user_id"])) + args = _args_of(entry) + if len(args) > 1: + write(" %s" % " ".join(args)) + + destination = entry["dst_host"] or entry["dst_ip"] or "?" + if entry["dst_host"] and entry["dst_ip"]: + destination = "%s (%s)" % (entry["dst_host"], entry["dst_ip"]) + write(" -> %s %s port %s" % (entry["protocol"] or "?", destination, entry["dst_port"])) + + if entry["provisional_name"]: + remaining = "" + if entry["provisional_expires"]: + left = int(entry["provisional_expires"] - time.time()) + remaining = ", %s left" % ("%dm%ds" % (left // 60, left % 60) if left > 0 + else "expired") + write(" currently %s for %s%s (temporary rule %s)" % ( + entry["provisional_action"], entry["provisional_duration"], remaining, + entry["provisional_name"])) + write("") + + +def render_decision(decision, write): + rule = decision.build() + if rule is None: + write(" no match selected, use 'e' to pick one") + return + write(" proposed rule: %s" % rule.name) + for line in rules.describe_rule(rule).split("\n"): + write(" %s" % line) + for warning in decision.warnings(): + write(" ! %s" % warning) + error = decision.validate() + if error is not None: + write(" ! the daemon would refuse this rule: %s" % error) + write("") + + +def render_details(entry, con, write): + write(" node %s" % entry["node"]) + write(" signature %s" % entry["signature"]) + write(" executable %s" % (entry["process_path"] or "?")) + write(" command line %s" % " ".join(_args_of(entry))) + write(" working dir %s" % (entry["process_cwd"] or "?")) + write(" pid / uid %s / %s" % (entry["process_id"], entry["user_id"])) + write(" destination %s %s:%s" % (entry["protocol"], entry["dst_host"] or entry["dst_ip"], + entry["dst_port"])) + if entry["dst_host"] and entry["dst_ip"]: + write(" address %s" % entry["dst_ip"]) + try: + checksums = json.loads(entry["process_checksums"] or "{}") + except ValueError: + checksums = {} + for key, value in checksums.items(): + write(" %-13s %s" % (key, value)) + write("") + + +def edit_menu(decision, read, write): + """changes the rule before it's applied. Returns True to apply it.""" + while True: + ops = decision.operators() + match = rules.describe_operator(ops[0]) if len(ops) > 0 else "(none)" + write("") + write(" 1) match on %s" % match) + write(" 2) action %s" % decision.action) + write(" 3) duration %s" % decision.duration) + write(" 4) name %s" % decision.name) + write(" 5) also require %s" % ( + ", ".join([rules.describe_operator(o) for o in ops[1:]]) if len(ops) > 1 else "(nothing)")) + write(" 6) precedence %s" % ("yes" if decision.precedence else "no")) + write(" a) apply c) cancel") + choice = read(" edit [1-6,a,c]: ").strip().lower() + + if choice == "a": + error = decision.validate() + if error is not None: + write(" cannot apply: %s" % error) + continue + return True + if choice in ("c", "q", ""): + return False + if choice == "1": + _choose_match(decision, read, write) + elif choice == "2": + _choose_action(decision, read, write) + elif choice == "3": + _choose_duration(decision, read, write) + elif choice == "4": + value = read(" rule name: ").strip() + if value != "": + decision.name = value + elif choice == "5": + _choose_extra(decision, read, write) + elif choice == "6": + decision.precedence = not decision.precedence + else: + write(" ?") + + +def apply_decision(db, entry, rule): + """queues the rule for the service to send, and closes the queue entry. + + Two notifications: the temporary rule we answered with is removed first, so + that it doesn't keep allowing the connection until it expires, then the real + rule is installed. Deleting a rule the daemon no longer has does nothing, so + it's safe even after the temporary rule expired on its own. + """ + from google.protobuf import json_format + from opensnitch.cli.proto import ui_pb2 + + if entry["provisional_name"]: + stale = ui_pb2.Rule(name=entry["provisional_name"]) + # the daemon only reads the name of the rule to delete, but it refuses a + # rule without an operator, so give it one + stale.operator.type = RuleConsts.RULE_TYPE_SIMPLE + stale.operator.operand = "true" + stale.operator.data = "" + db.queue_notification(entry["node"], ui_pb2.DELETE_RULE, + json_format.MessageToJson(stale), pending_id=entry["id"]) + + db.queue_notification(entry["node"], ui_pb2.CHANGE_RULE, + json_format.MessageToJson(rule), pending_id=entry["id"]) + db.set_pending_state(entry["id"], db_state_decided(), json_format.MessageToJson(rule)) + + +def db_state_decided(): + from opensnitch.cli import db as dbmod + + return dbmod.STATE_DECIDED + + +def review_loop(db, entries, config, read=input, write=print): + """walks the queue. Returns the number of rules queued for the daemon.""" + default_duration = RuleConsts.DURATION_ALWAYS + applied = 0 + total = len(entries) + + for index, entry in enumerate(entries, start=1): + con = entry_connection(entry) + taken = db.rule_names(entry["node"]) + decision = Decision(entry, con, RuleConsts.ACTION_ALLOW, default_duration, taken) + + render_entry(entry, con, index, total, write) + render_decision(decision, write) + + while True: + choice = read(" [y]es [n]o [r]eject [e]dit [s]kip [d]rop [i]nfo [q]uit ? ").strip().lower() + + if choice == "y": + pass + elif choice == "n": + decision.action = RuleConsts.ACTION_DENY + elif choice == "r": + decision.action = RuleConsts.ACTION_REJECT + elif choice == "e": + if not edit_menu(decision, read, write): + render_decision(decision, write) + continue + elif choice == "s": + break + elif choice == "d": + db.set_pending_state(entry["id"], "dropped") + write(" dropped, no rule created") + break + elif choice == "i": + render_details(entry, con, write) + continue + elif choice == "q": + return applied + elif choice in ("?", "h"): + write(HELP) + continue + else: + # like git add -p, an empty answer does nothing + continue + + error = decision.validate() + if error is not None: + write(" the daemon would refuse this rule: %s" % error) + continue + + rule = decision.build() + apply_decision(db, entry, rule) + applied += 1 + write(" queued: %s %s as '%s'" % (rule.action, rule.duration, rule.name)) + break + + return applied + + +def _choose_match(decision, read, write): + write("") + for i, cand in enumerate(decision.candidates, start=1): + write(" %2d) %-40s %s %s" % (i, cand["data"], cand["type"], cand["operand"])) + write(" c) something else, typed by hand") + choice = read(" match on [1-%d,c]: " % len(decision.candidates)).strip().lower() + + if choice == "c": + custom = _custom_operand(read, write) + if custom is not None: + decision.selected = custom + return + try: + index = int(choice) - 1 + except ValueError: + return + if 0 <= index < len(decision.candidates): + decision.selected = decision.candidates[index] + + +def _custom_operand(read, write): + op_type = read(" type [%s]: " % "/".join(RuleConsts.RulesTypes)).strip() + if op_type not in RuleConsts.RulesTypes: + write(" unknown type") + return None + operand = read(" operand (for example dest.host, process.path): ").strip() + if operand == "": + write(" an operand is required") + return None + data = read(" value: ").strip() + + candidate = {"label": "custom", "type": op_type, "operand": operand, "data": data} + error = rules.validate_operator( + rules.new_operator(op_type, operand, data)) + if error is not None: + write(" the daemon would refuse this: %s" % error) + return None + return candidate + + +def _choose_action(decision, read, write): + write(" 1) allow 2) deny 3) reject") + choice = read(" action [1-3]: ").strip() + decision.action = {"1": RuleConsts.ACTION_ALLOW, + "2": RuleConsts.ACTION_DENY, + "3": RuleConsts.ACTION_REJECT}.get(choice, decision.action) + + +def _choose_duration(decision, read, write): + for i, duration in enumerate(durations.COMMON, start=1): + write(" %2d) %s" % (i, duration)) + write(" c) something else") + choice = read(" duration [1-%d,c]: " % len(durations.COMMON)).strip().lower() + if choice == "c": + value = read(" duration (30s, 5m, 1h30m, always, until restart): ").strip() + error = durations.validate(value) + if error is not None: + write(" %s" % error) + return + decision.duration = value + return + try: + decision.duration = durations.COMMON[int(choice) - 1] + except (ValueError, IndexError): + pass + + +def _choose_extra(decision, read, write): + available = [c for c in decision.candidates if c is not decision.selected] + write("") + for i, cand in enumerate(available, start=1): + mark = "*" if cand in decision.extra else " " + write(" %s %2d) %-38s %s %s" % (mark, i, cand["data"], cand["type"], cand["operand"])) + write(" (a starred entry is already required; picking it again removes it)") + choice = read(" toggle [1-%d, empty to go back]: " % len(available)).strip() + try: + cand = available[int(choice) - 1] + except (ValueError, IndexError): + return + if cand in decision.extra: + decision.extra.remove(cand) + else: + decision.extra.append(cand) From 8c45c9afbe6fef92372c780a2d59c2ef20dce31c Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 20:31:46 -0700 Subject: [PATCH 08/24] cli: add the opensnitch-cli command opensnitch-cli serve answers the daemon and records connections opensnitch-cli review goes through the queue one by one opensnitch-cli pending lists what is waiting opensnitch-cli allow|deny|reject decides without prompting opensnitch-cli rules|nodes|status grpc is imported only by the commands that need it, so reviewing the queue works on a machine where the service half isn't installed. The service unit and the example configuration are not listed in setup.py's data_files on purpose: writing to /lib/systemd/system from setup.py would also happen on a plain pip install. They are in resources/ for the packaging to install. The unit is meant to be shipped disabled, since opensnitch-ui and opensnitch-cli serve cannot both own the same socket. Co-Authored-By: Claude Opus 5 --- ui/bin/opensnitch-cli | 25 ++ ui/opensnitch/cli/main.py | 310 +++++++++++++++++++++++ ui/resources/cli.conf.example | 64 +++++ ui/resources/init/opensnitch-cli.service | 29 +++ ui/setup.py | 7 +- 5 files changed, 434 insertions(+), 1 deletion(-) create mode 100755 ui/bin/opensnitch-cli create mode 100644 ui/opensnitch/cli/main.py create mode 100644 ui/resources/cli.conf.example create mode 100644 ui/resources/init/opensnitch-cli.service diff --git a/ui/bin/opensnitch-cli b/ui/bin/opensnitch-cli new file mode 100755 index 0000000000..7ded3b41dd --- /dev/null +++ b/ui/bin/opensnitch-cli @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2026 The OpenSnitch Authors +# +# This file is part of OpenSnitch. +# +# OpenSnitch is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenSnitch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenSnitch. If not, see . + +import sys + +from opensnitch.cli.main import main + +if __name__ == '__main__': + sys.exit(main()) diff --git a/ui/opensnitch/cli/main.py b/ui/opensnitch/cli/main.py new file mode 100644 index 0000000000..dd17acef43 --- /dev/null +++ b/ui/opensnitch/cli/main.py @@ -0,0 +1,310 @@ +# Copyright (C) 2026 The OpenSnitch Authors +# +# This file is part of OpenSnitch. +# +# OpenSnitch is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenSnitch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenSnitch. If not, see . + +"""Command line of opensnitch-cli. + +grpc is only imported by the commands that need it, so that reviewing the queue +works on a machine where the service half isn't installed. +""" + +import argparse +import json +import logging +import os +import sys + +from opensnitch.version import version +from opensnitch.rule_consts import RuleConsts +from opensnitch.cli.config import Config, ConfigError + +LOG_FORMAT = '%(asctime)s - [%(levelname)s][%(filename)s:%(lineno)d] %(message)s' + + +def setup_logging(config, level=None): + level = level or config.get("log", "level") + handlers = [] + log_file = config.get("log", "file") + if log_file != "": + handlers.append(logging.FileHandler(log_file)) + else: + handlers.append(logging.StreamHandler(sys.stderr)) + logging.basicConfig(level=getattr(logging, level.upper()), format=LOG_FORMAT, + handlers=handlers, force=True) + + +def open_db(config): + from opensnitch.cli import db as dbmod + + return dbmod.Database(config.db_path()) + + +def cmd_serve(args, config): + from opensnitch.cli.server import Server + + if args.socket is not None: + config._parser.set("server", "address", args.socket) + + server = Server(config) + try: + server.serve_forever() + except RuntimeError as e: + print("opensnitch-cli: %s" % e, file=sys.stderr) + return 1 + return 0 + + +def _entry_summary(entry): + destination = entry["dst_host"] or entry["dst_ip"] or "?" + return "%-5s %-28s %-38s %s" % ( + entry["id"], + (entry["process_path"] or "?")[-28:], + "%s:%s" % (destination, entry["dst_port"]), + "%sx" % entry["hits"]) + + +def cmd_pending(args, config): + db = open_db(config) + entries = db.pending(node=args.node, limit=args.limit) + + if args.json: + print(json.dumps([dict(e) for e in entries], indent=2)) + return 0 + + if len(entries) == 0: + print("nothing waiting to be reviewed") + return 0 + + print("%-5s %-28s %-38s %s" % ("ID", "PROCESS", "DESTINATION", "SEEN")) + for entry in entries: + print(_entry_summary(entry)) + return 0 + + +def cmd_review(args, config): + from opensnitch.cli import review + + db = open_db(config) + entries = db.pending(node=args.node, limit=args.limit) + if len(entries) == 0: + print("nothing waiting to be reviewed") + return 0 + + try: + applied = review.review_loop(db, entries, config) + except (KeyboardInterrupt, EOFError): + print("") + applied = 0 + + if applied: + print("\n%d rule(s) queued. The service applies them within a second; " + "run 'opensnitch-cli status' to check." % applied) + return 0 + + +def cmd_decide(args, config): + """allow / deny / reject without the interactive loop.""" + from opensnitch.cli import review, rules + + db = open_db(config) + entry = db.get_pending(args.id) + if entry is None: + print("opensnitch-cli: no queue entry with id %s" % args.id, file=sys.stderr) + return 1 + + con = review.entry_connection(entry) + decision = review.Decision(entry, con, args.action, args.duration, + db.rule_names(entry["node"])) + + if args.match is not None: + matched = [c for c in decision.candidates if c["operand"] == args.match] + if len(matched) == 0: + print("opensnitch-cli: '%s' is not something this connection can be matched " + "on. Available: %s" % ( + args.match, ", ".join(sorted(set(c["operand"] for c in decision.candidates)))), + file=sys.stderr) + return 1 + decision.selected = matched[0] + if args.name is not None: + decision.name = args.name + + error = decision.validate() + if error is not None: + print("opensnitch-cli: %s" % error, file=sys.stderr) + return 1 + + rule = decision.build() + review.apply_decision(db, entry, rule) + print("queued: %s %s as '%s'" % (rule.action, rule.duration, rule.name)) + return 0 + + +def cmd_drop(args, config): + db = open_db(config) + entry = db.get_pending(args.id) + if entry is None: + print("opensnitch-cli: no queue entry with id %s" % args.id, file=sys.stderr) + return 1 + db.set_pending_state(args.id, "dropped") + print("dropped entry %s, no rule created" % args.id) + return 0 + + +def cmd_rules(args, config): + db = open_db(config) + entries = db.rules(node=args.node) + if args.json: + print(json.dumps([dict(e) for e in entries], indent=2)) + return 0 + if len(entries) == 0: + print("no rules known. They are read from each node when it connects.") + return 0 + print("%-8s %-40s %-8s %s" % ("ENABLED", "NAME", "ACTION", "DURATION")) + for rule in entries: + print("%-8s %-40s %-8s %s" % ( + "yes" if rule["enabled"] else "no", rule["name"][:40], rule["action"], + rule["duration"])) + return 0 + + +def cmd_nodes(args, config): + db = open_db(config) + nodes = db.nodes() + if args.json: + print(json.dumps([dict(n) for n in nodes], indent=2)) + return 0 + if len(nodes) == 0: + print("no node has connected yet") + return 0 + print("%-28s %-20s %-8s %s" % ("ADDRESS", "HOSTNAME", "ONLINE", "LAST SEEN")) + for node in nodes: + print("%-28s %-20s %-8s %s" % (node["addr"], node["hostname"] or "?", + "yes" if node["online"] else "no", node["last_seen"])) + return 0 + + +def cmd_status(args, config): + db = open_db(config) + nodes = db.nodes() + online = len([n for n in nodes if n["online"]]) + errors = db.outbox_errors() + queued = len(db.queued_notifications(limit=1000)) + + status = { + "pending": db.pending_count(), + "nodes": len(nodes), + "nodes_online": online, + "notifications_queued": queued, + "notifications_failed": len(errors), + "unreviewed_action": config.get("policy", "unreviewed_action"), + "unreviewed_duration": config.get("policy", "unreviewed_duration"), + "database": config.db_path(), + } + + if args.json: + print(json.dumps(status, indent=2)) + else: + for key, value in status.items(): + print("%-22s %s" % (key.replace("_", " "), value)) + for row in errors: + print("\nrejected by the daemon: %s" % row["last_error"]) + print(" %s" % row["rule_json"].replace("\n", " ")) + + # so that a monitoring system notices rules that never made it + return 1 if len(errors) > 0 else 0 + + +def build_parser(): + parser = argparse.ArgumentParser( + prog="opensnitch-cli", + description="Review and answer OpenSnitch connection prompts from a terminal.") + parser.add_argument("--version", action="version", version="opensnitch-cli %s" % version) + parser.add_argument("--config", help="path to cli.conf") + parser.add_argument("--db", help="path to the queue database, overrides the config file") + parser.add_argument("--log-level", choices=("debug", "info", "warning", "error")) + + subparsers = parser.add_subparsers(dest="command") + + serve = subparsers.add_parser("serve", help="answer the daemon and record connections") + serve.add_argument("--socket", help="address to listen on, overrides the config file") + serve.set_defaults(func=cmd_serve) + + pending = subparsers.add_parser("pending", help="list connections waiting to be reviewed") + pending.add_argument("--node") + pending.add_argument("--limit", type=int) + pending.add_argument("--json", action="store_true") + pending.set_defaults(func=cmd_pending) + + review_cmd = subparsers.add_parser("review", help="go through the queue one by one") + review_cmd.add_argument("--node") + review_cmd.add_argument("--limit", type=int) + review_cmd.set_defaults(func=cmd_review) + + for action in (RuleConsts.ACTION_ALLOW, RuleConsts.ACTION_DENY, RuleConsts.ACTION_REJECT): + decide = subparsers.add_parser(action, help="%s a queue entry without prompting" % action) + decide.add_argument("id", type=int) + decide.add_argument("--match", help="operand to match on, for example dest.host") + decide.add_argument("--duration", default=RuleConsts.DURATION_ALWAYS) + decide.add_argument("--name") + decide.set_defaults(func=cmd_decide, action=action) + + drop = subparsers.add_parser("drop", help="remove a queue entry without creating a rule") + drop.add_argument("id", type=int) + drop.set_defaults(func=cmd_drop) + + rules_cmd = subparsers.add_parser("rules", help="rules the daemon reported on connecting") + rules_cmd.add_argument("--node") + rules_cmd.add_argument("--json", action="store_true") + rules_cmd.set_defaults(func=cmd_rules) + + nodes = subparsers.add_parser("nodes", help="daemons that have connected") + nodes.add_argument("--json", action="store_true") + nodes.set_defaults(func=cmd_nodes) + + status = subparsers.add_parser("status", help="queue depth, nodes and failed rules") + status.add_argument("--json", action="store_true") + status.set_defaults(func=cmd_status) + + return parser + + +def main(argv=None): + parser = build_parser() + args = parser.parse_args(argv) + + if getattr(args, "func", None) is None: + parser.print_help() + return 2 + + try: + config = Config(path=args.config) + except ConfigError as e: + print("opensnitch-cli: %s" % e, file=sys.stderr) + return 2 + + if args.db is not None: + config._parser.set("db", "path", args.db) + + setup_logging(config, args.log_level) + + try: + return args.func(args, config) + except PermissionError as e: + print("opensnitch-cli: %s\nThe queue is only readable by root, try with sudo." % e, + file=sys.stderr) + return 1 + except KeyboardInterrupt: + return 130 diff --git a/ui/resources/cli.conf.example b/ui/resources/cli.conf.example new file mode 100644 index 0000000000..ceea2ee98a --- /dev/null +++ b/ui/resources/cli.conf.example @@ -0,0 +1,64 @@ +# Configuration of opensnitch-cli, the headless client. +# +# Copy to /etc/opensnitch/cli.conf, or to ~/.config/opensnitch/cli.conf. +# Every value below is the default: with no configuration file at all, +# opensnitch-cli behaves exactly as if this file were in place. +# +# @doc: https://github.com/evilsocket/opensnitch/wiki + +[server] +# Where the daemon connects to. Must match Server.Address in the daemon's +# /etc/opensnitchd/default-config.json. Only one program can listen here, so +# opensnitch-cli serve and opensnitch-ui cannot both run against the same daemon. +address = unix:///tmp/osui.sock + +# simple, tls-simple or tls-mutual. The same values, and the same certificates, +# the graphical interface uses. +auth_type = simple +tls_ca_cert = +tls_cert = +tls_key = + +# One worker is taken for as long as each node's notification stream is open, +# another while a connection is being asked about, so roughly three per node. +max_workers = 10 +max_clients = 0 +keepalive = 5000 +keepalive_timeout = 20000 +max_message_length = 4194304 + +[policy] +# What to answer for a connection nobody has reviewed yet: allow, deny or reject. +# +# The default is allow, so a server keeps working while the queue waits for you. +# The connection is still recorded for review, and the decision you take governs +# every later connection. Set this to deny if this machine should block anything +# that has not been approved. +unreviewed_action = allow + +# How long that answer lasts before the connection is asked about again. +# Long enough not to ask about every packet, short enough that a connection you +# never review comes back rather than being allowed for good. +# Days and weeks are not accepted: the daemon parses this with Go's +# time.ParseDuration, which only knows ns, us, ms, s, m and h. +unreviewed_duration = 1h + +# What the daemon should do while it is waiting for an answer to another +# connection, or if this program stops responding. allow or deny. +default_action = allow + +# Stop recording new connections past this many unreviewed entries. Connections +# are still answered, they just stop being added to the queue; opensnitch-cli +# status reports how many were missed. +queue_max = 1000 + +[db] +path = /var/lib/opensnitch/cli.db +# Reviewed entries are removed after this many days. 0 keeps them forever. +retention_days = 30 + +[log] +level = info +# empty logs to stderr, which is what the systemd unit wants +file = +store_alerts = false diff --git a/ui/resources/init/opensnitch-cli.service b/ui/resources/init/opensnitch-cli.service new file mode 100644 index 0000000000..f294013367 --- /dev/null +++ b/ui/resources/init/opensnitch-cli.service @@ -0,0 +1,29 @@ +[Unit] +Description=OpenSnitch headless client +Documentation=https://github.com/evilsocket/opensnitch/wiki +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/opensnitch-cli serve +Restart=always +RestartSec=10 + +# /var/lib/opensnitch, 0700. The queue decides what this machine is allowed to +# connect to, so it must not be readable by everyone. +StateDirectory=opensnitch + +# The default socket is /tmp/osui.sock, and a private /tmp would hide it from +# the daemon. Point server.address at /run/opensnitch/cli.sock in cli.conf, +# uncomment RuntimeDirectory below and set PrivateTmp=yes for a tidier setup. +PrivateTmp=no +ReadWritePaths=/tmp +#RuntimeDirectory=opensnitch +#RuntimeDirectoryMode=0700 + +ProtectSystem=strict +ProtectHome=yes +NoNewPrivileges=yes + +[Install] +WantedBy=multi-user.target diff --git a/ui/setup.py b/ui/setup.py index ee11d73eae..e1a4d0c7ea 100644 --- a/ui/setup.py +++ b/ui/setup.py @@ -34,5 +34,10 @@ ('/usr/share/icons/hicolor/48x48/apps', ['resources/icons/48x48/opensnitch-ui.png']), ('/usr/share/icons/hicolor/64x64/apps', ['resources/icons/64x64/opensnitch-ui.png']), ('/usr/share/metainfo', ['resources/io.github.evilsocket.opensnitch.appdata.xml'])], - scripts = [ 'bin/opensnitch-ui' ], + # opensnitch-cli's service unit and example configuration are not listed + # in data_files on purpose: writing to /lib/systemd/system from setup.py + # would also happen on a plain "pip install". They live in + # resources/init/ and resources/cli.conf.example, for the packaging to + # install. + scripts = [ 'bin/opensnitch-ui', 'bin/opensnitch-cli' ], zip_safe=False) From 75e7aabfc98f8206bbc1e7a1c6dd89af75004598 Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 20:31:46 -0700 Subject: [PATCH 09/24] cli: add tests Run with "cd ui/tests; pytest -v cli/". They need neither Qt nor a display, so they can be run with only grpcio, protobuf and python-slugify installed. test_no_qt.py imports the whole package in a fresh interpreter and fails if anything reached Qt, which is the constraint the client exists for. test_operands_parity.py checks that the terminal client and the pop-up build exactly the same operator for the same connection, so the two cannot drift apart. It is skipped where PyQt6 is missing, which is the normal case on a server. The rest covers the queue and the outbox state machine including the daemon rejecting a rule, the signature that decides when two connections are the same thing, the rule validation, the five RPCs against a fake context, and the review loop driven by scripted answers. Co-Authored-By: Claude Opus 5 --- ui/tests/README.md | 10 ++ ui/tests/cli/__init__.py | 0 ui/tests/cli/conftest.py | 51 +++++++ ui/tests/cli/test_db.py | 113 +++++++++++++++ ui/tests/cli/test_no_qt.py | 54 ++++++++ ui/tests/cli/test_operands.py | 109 +++++++++++++++ ui/tests/cli/test_operands_parity.py | 77 +++++++++++ ui/tests/cli/test_policy.py | 121 ++++++++++++++++ ui/tests/cli/test_review.py | 200 +++++++++++++++++++++++++++ ui/tests/cli/test_rules.py | 130 +++++++++++++++++ ui/tests/cli/test_service.py | 199 ++++++++++++++++++++++++++ 11 files changed, 1064 insertions(+) create mode 100644 ui/tests/cli/__init__.py create mode 100644 ui/tests/cli/conftest.py create mode 100644 ui/tests/cli/test_db.py create mode 100644 ui/tests/cli/test_no_qt.py create mode 100644 ui/tests/cli/test_operands.py create mode 100644 ui/tests/cli/test_operands_parity.py create mode 100644 ui/tests/cli/test_policy.py create mode 100644 ui/tests/cli/test_review.py create mode 100644 ui/tests/cli/test_rules.py create mode 100644 ui/tests/cli/test_service.py diff --git a/ui/tests/README.md b/ui/tests/README.md index e94453b780..987687269d 100644 --- a/ui/tests/README.md +++ b/ui/tests/README.md @@ -4,6 +4,16 @@ We use pytest [0] to pytest-qt [1] to test GUI code. To run the tests: `cd tests; pytest -v` +The tests under `cli/` cover opensnitch-cli, which runs on servers without Qt. +They don't need PyQt6 or a display, so they can be run on their own with only +grpcio, protobuf and python-slugify installed: + + cd tests; pytest -v cli/ + +`cli/test_no_qt.py` fails if anything in opensnitch/cli/ ends up importing Qt, +and `cli/test_operands_parity.py` checks that the terminal client builds exactly +the same rules as the pop-up does (it is skipped when PyQt6 is missing). + TODO: - test service class (Service.py) - test events window (stats.py): diff --git a/ui/tests/cli/__init__.py b/ui/tests/cli/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/tests/cli/conftest.py b/ui/tests/cli/conftest.py new file mode 100644 index 0000000000..227c5a60a1 --- /dev/null +++ b/ui/tests/cli/conftest.py @@ -0,0 +1,51 @@ +# conftest.py - pytest configuration for the opensnitch-cli tests +# +# opensnitch-cli runs on servers without Qt, so its tests must not need a +# QApplication. The autouse fixtures of the parent conftest.py are replaced +# here by ones that do nothing: pytest resolves fixtures from the closest +# conftest, so the GUI tests keep using the originals. + +import os +import tempfile + +import pytest + +from opensnitch.cli.config import Config +from opensnitch.cli import db as dbmod + + +@pytest.fixture(autouse=True) +def mock_message_dialogs(): + """the GUI's modal dialogs don't exist here.""" + yield None + + +@pytest.fixture(autouse=True) +def reset_node_before_each_test(): + """no Nodes singleton and no QApplication in the CLI.""" + yield None + + +@pytest.fixture +def config(tmp_path): + """a configuration pointing at a throw away database.""" + path = tmp_path / "cli.conf" + path.write_text("[db]\npath = %s\n" % (tmp_path / "cli.db")) + return Config(path=str(path)) + + +@pytest.fixture +def db(config): + database = dbmod.Database(config.db_path()) + yield database + database.close() + + +@pytest.fixture +def connection(): + from opensnitch.cli.proto import ui_pb2 + + return ui_pb2.Connection( + protocol="tcp", dst_ip="140.82.121.6", dst_host="api.github.com", dst_port=443, + user_id=1000, process_id=41233, process_path="/usr/bin/curl", + process_args=["/usr/bin/curl", "-sSL", "https://api.github.com/repos"]) diff --git a/ui/tests/cli/test_db.py b/ui/tests/cli/test_db.py new file mode 100644 index 0000000000..d9161813a0 --- /dev/null +++ b/ui/tests/cli/test_db.py @@ -0,0 +1,113 @@ +# +# pytest -v cli/test_db.py +# + +from opensnitch.cli import db as dbmod + + +class TestQueue: + + def test_records_a_connection(self, db, connection): + entry_id, is_new = db.record_pending("n", "sig", connection, {}) + + assert is_new + assert db.pending_count() == 1 + assert db.get_pending(entry_id)["process_path"] == "/usr/bin/curl" + + def test_counts_repeats(self, db, connection): + first, _ = db.record_pending("n", "sig", connection, {}) + second, is_new = db.record_pending("n", "sig", connection, {}) + + assert first == second + assert not is_new + assert db.get_pending(first)["hits"] == 2 + + def test_each_node_has_its_own_queue(self, db, connection): + db.record_pending("a", "sig", connection, {}) + db.record_pending("b", "sig", connection, {}) + assert db.pending_count() == 2 + + def test_deciding_removes_it_from_the_queue(self, db, connection): + entry_id, _ = db.record_pending("n", "sig", connection, {}) + db.set_pending_state(entry_id, dbmod.STATE_DECIDED, '{"name":"r"}') + + assert db.pending_count() == 0 + assert db.get_pending(entry_id)["decided_rule"] == '{"name":"r"}' + + def test_expired_provisional_rules_are_forgotten(self, db, connection): + entry_id, _ = db.record_pending("n", "sig", connection, + {"name": "cli-auto-x", "expires_in": -1}) + assert db.expire_provisionals() == 1 + assert db.get_pending(entry_id)["provisional_name"] is None + + def test_a_live_provisional_rule_is_kept(self, db, connection): + entry_id, _ = db.record_pending("n", "sig", connection, + {"name": "cli-auto-x", "expires_in": 3600}) + assert db.expire_provisionals() == 0 + assert db.get_pending(entry_id)["provisional_name"] == "cli-auto-x" + + +class TestOutbox: + + def test_lifecycle(self, db): + outbox_id = db.queue_notification("n", 10, '{"name":"r"}') + assert len(db.queued_notifications()) == 1 + + db.mark_sent(outbox_id, 555) + assert len(db.queued_notifications()) == 0 + assert db.get_outbox(outbox_id)["state"] == dbmod.OUT_SENT + + db.mark_result(555, True) + assert db.get_outbox(outbox_id)["state"] == dbmod.OUT_DONE + + def test_a_rejected_rule_is_kept_with_its_reason(self, db): + outbox_id = db.queue_notification("n", 10, "{}") + db.mark_sent(outbox_id, 556) + db.mark_result(556, False, "invalid regexp") + + errors = db.outbox_errors() + assert len(errors) == 1 + assert errors[0]["last_error"] == "invalid regexp" + + def test_unanswered_notifications_are_sent_again_on_restart(self, db): + """anything still marked as sent was in flight when the service stopped.""" + outbox_id = db.queue_notification("n", 10, "{}") + db.mark_sent(outbox_id, 557) + + assert db.requeue_sent() == 1 + assert db.get_outbox(outbox_id)["state"] == dbmod.OUT_QUEUED + + def test_a_decision_survives_the_service_being_down(self, db): + """review writes, serve reads later: nothing is lost in between.""" + db.queue_notification("n", 10, '{"name":"r"}') + assert len(db.queued_notifications()) == 1 + + +class TestConcurrentAccess: + + def test_two_connections_share_the_file(self, config, connection): + """review and serve are separate processes on the same database.""" + writer = dbmod.Database(config.db_path()) + reader = dbmod.Database(config.db_path()) + + writer.record_pending("n", "sig", connection, {}) + assert reader.pending_count() == 1 + + reader.record_pending("n", "sig2", connection, {}) + assert writer.pending_count() == 2 + + writer.close() + reader.close() + + +class TestPurge: + + def test_keeps_entries_still_waiting(self, db, connection): + db.record_pending("n", "sig", connection, {}) + assert db.purge(retention_days=30) == 0 + assert db.pending_count() == 1 + + def test_disabled_by_zero(self, db, connection): + entry_id, _ = db.record_pending("n", "sig", connection, {}) + db.set_pending_state(entry_id, dbmod.STATE_DECIDED) + assert db.purge(retention_days=0) == 0 diff --git a/ui/tests/cli/test_no_qt.py b/ui/tests/cli/test_no_qt.py new file mode 100644 index 0000000000..0b72d52bf9 --- /dev/null +++ b/ui/tests/cli/test_no_qt.py @@ -0,0 +1,54 @@ +# +# pytest -v cli/test_no_qt.py +# +# opensnitch-cli must run on servers where PyQt is not installed. This walks the +# package and fails if anything reaches Qt, directly or through an import. + +import importlib +import os +import subprocess +import sys + +MODULES = ( + "opensnitch.rule_consts", + "opensnitch.operands", + "opensnitch.proto", + "opensnitch.cli.config", + "opensnitch.cli.durations", + "opensnitch.cli.db", + "opensnitch.cli.proto", + "opensnitch.cli.rules", + "opensnitch.cli.policy", + "opensnitch.cli.service", + "opensnitch.cli.server", + "opensnitch.cli.review", + "opensnitch.cli.main", +) + + +class TestNoQt: + + def test_modules_do_not_import_qt(self): + """importing the whole CLI must not pull PyQt in. + + Run in a new interpreter, so that a GUI test that ran earlier in the + session can't make this pass by accident. + """ + script = ( + "import sys\n" + "for name in %r:\n" + " __import__(name)\n" + "leaked = [m for m in sys.modules if m.startswith('PyQt')]\n" + "print(','.join(leaked))\n" % (MODULES,) + ) + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join(sys.path) + result = subprocess.run([sys.executable, "-c", script], + capture_output=True, text=True, env=env) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "", \ + "the CLI imported Qt: %s" % result.stdout.strip() + + def test_modules_import(self): + for name in MODULES: + importlib.import_module(name) diff --git a/ui/tests/cli/test_operands.py b/ui/tests/cli/test_operands.py new file mode 100644 index 0000000000..fcf968839f --- /dev/null +++ b/ui/tests/cli/test_operands.py @@ -0,0 +1,109 @@ +# +# pytest -v cli/test_operands.py +# +# The patterns built here end up in the daemon's rules, and the pop-up builds +# the same ones. test_operands_parity.py checks they stay identical. + +import pytest + +from opensnitch import operands +from opensnitch.cli.proto import ui_pb2 + + +def make_connection(**kwargs): + fields = dict(protocol="tcp", dst_ip="140.82.121.6", dst_host="api.github.com", + dst_port=443, user_id=1000, process_id=41233, + process_path="/usr/bin/curl", + process_args=["/usr/bin/curl", "-sSL", "https://api.github.com"]) + fields.update(kwargs) + return ui_pb2.Connection(**fields) + + +class TestBuilders: + + def test_host_wildcard_matches_the_domain_and_its_subdomains(self): + assert operands.from_dest_host_wildcard("github.com") == \ + ("regexp", "dest.host", r"^(|.*\.)github\.com$") + + def test_address_wildcard(self): + assert operands.from_dest_ip_wildcard("140.82.*") == \ + ("regexp", "dest.ip", r"140\.82\..*") + + def test_command_line(self): + con = make_connection() + assert operands.from_process_command(con.process_args, con.process_path) == \ + ("simple", "process.command", "/usr/bin/curl -sSL https://api.github.com") + + def test_command_line_falls_back_to_the_executable(self): + assert operands.from_process_command([], "/usr/bin/curl") == \ + ("simple", "process.path", "/usr/bin/curl") + + def test_appimage_ignores_the_mount_point(self): + _, operand, data = operands.from_appimage_path("/tmp/.mount_Eden8xK2p/usr/bin/eden") + assert operand == "process.path" + assert "[0-9A-Za-z]+" in data and data.endswith("eden$") + + def test_snap_ignores_the_revision(self): + _, operand, data = operands.from_snap_path("/snap/firefox/4259/usr/lib/firefox/firefox") + assert operand == "process.path" + assert "[0-9]+" in data + + +class TestCandidates: + + def test_offers_the_executable_first(self): + found = operands.candidates(make_connection()) + assert found[0]["operand"] == "process.path" + assert found[0]["data"] == "/usr/bin/curl" + + def test_offers_command_line_host_address_and_wildcards(self): + found = operands.candidates(make_connection()) + pairs = [(c["type"], c["operand"], c["data"]) for c in found] + + assert ("simple", "process.command", "/usr/bin/curl -sSL https://api.github.com") in pairs + assert ("simple", "dest.host", "api.github.com") in pairs + assert ("regexp", "dest.host", r"^(|.*\.)github\.com$") in pairs + assert ("simple", "dest.ip", "140.82.121.6") in pairs + assert ("regexp", "dest.ip", r"140\.82\..*") in pairs + assert ("network", "dest.network", "140.82.121.0/24") in pairs + assert ("simple", "dest.port", "443") in pairs + assert ("simple", "user.id", "1000") in pairs + + def test_skips_what_is_not_known(self): + con = ui_pb2.Connection(protocol="tcp", dst_ip="10.0.0.1", dst_port=25, + process_path="", user_id=0) + pairs = [c["operand"] for c in operands.candidates(con)] + assert "process.path" not in pairs + assert "dest.ip" in pairs + + def test_ipv6(self): + con = make_connection(dst_ip="2606:2800:220:1:248:1893:25c8:1946", dst_host="") + pairs = [(c["operand"], c["data"]) for c in operands.candidates(con)] + assert any(operand == "dest.network" and data.endswith("/64") for operand, data in pairs) + + def test_appimage_offered_first_for_an_appimage(self): + con = make_connection(process_path="/tmp/.mount_Eden8xK2p/usr/bin/eden") + assert operands.candidates(con)[0]["type"] == "regexp" + + def test_every_candidate_is_usable(self): + """nothing we offer may be something the daemon would refuse.""" + from opensnitch.cli import rules + + for cand in operands.candidates(make_connection()): + op = rules.new_operator(cand["type"], cand["operand"], cand["data"]) + assert rules.validate_operator(op) is None, cand + + +class TestRE2: + + @pytest.mark.parametrize("pattern,expected_ok", [ + (r"^(|.*\.)github\.com$", True), + (r"140\.82\..*", True), + (r"^(?=x)", False), + (r"(?!x)", False), + (r"(?<=a)b", False), + (r"(a)\1", False), + (r"[", False), + ]) + def test_check_regexp(self, pattern, expected_ok): + assert (operands.check_regexp(pattern) is None) == expected_ok diff --git a/ui/tests/cli/test_operands_parity.py b/ui/tests/cli/test_operands_parity.py new file mode 100644 index 0000000000..78f1c3daf0 --- /dev/null +++ b/ui/tests/cli/test_operands_parity.py @@ -0,0 +1,77 @@ +# +# pytest -v cli/test_operands_parity.py +# +# The pop-up and opensnitch-cli must build exactly the same rules for the same +# connection: a rule created from a terminal has to behave like one created from +# the graphical interface. get_combo_operator() delegates to opensnitch.operands, +# and this makes sure it stays that way. +# +# Skipped where Qt is not installed, which is the normal case on a server. + +import pytest + +pytest.importorskip("PyQt6") + +from opensnitch import operands # noqa: E402 +from opensnitch.cli.proto import ui_pb2 # noqa: E402 +from opensnitch.dialogs.prompt import utils as prompt_utils # noqa: E402 + + +def make_connection(**kwargs): + fields = dict(protocol="tcp", dst_ip="140.82.121.6", dst_host="api.github.com", + dst_port=443, user_id=1000, process_id=41233, + process_path="/usr/bin/curl", + process_args=["/usr/bin/curl", "-sSL", "https://api.github.com"]) + fields.update(kwargs) + return ui_pb2.Connection(**fields) + + +CONNECTIONS = { + "curl": make_connection(), + "no args": make_connection(process_args=[]), + "empty arg": make_connection(process_args=[""]), + "appimage": make_connection(process_path="/tmp/.mount_Eden8xK2p/usr/bin/eden"), + "snap": make_connection(process_path="/snap/firefox/4259/usr/lib/firefox/firefox"), + "dotted path": make_connection(process_path="/opt/my.app/bin/my.bin"), +} + +# (field, what the combo shows, the value the builders take) +CASES = [ + (operands.FIELD_PROC_PATH, "from this executable", None), + (operands.FIELD_PROC_ARGS, "from this command line", None), + (operands.FIELD_PROC_ID, "from this PID", None), + (operands.FIELD_USER_ID, "from user 1000", None), + (operands.FIELD_DST_PORT, "to port 443", None), + (operands.FIELD_DST_IP, "to 140.82.121.6", None), + (operands.FIELD_DST_HOST, "api.github.com", "api.github.com"), + (operands.FIELD_DST_NETWORK, "to 140.82.121.0/24", "140.82.121.0/24"), + (operands.FIELD_DST_NETWORK, "to 140.0.0.0/8", "140.0.0.0/8"), + (operands.FIELD_REGEX_HOST, "to *.github.com", "github.com"), + (operands.FIELD_REGEX_HOST, "to *.co.uk", "co.uk"), + (operands.FIELD_REGEX_IP, "to 140.82.*", "140.82.*"), + (operands.FIELD_REGEX_IP, "to 140.*", "140.*"), + (operands.FIELD_APPIMAGE, "from /tmp/.mount_Eden8*/eden", None), + (operands.FIELD_SNAP, "from /snap/firefox/*/usr/lib/firefox/firefox", None), +] + + +class TestParity: + + @pytest.mark.parametrize("con_name", list(CONNECTIONS.keys())) + def test_same_operator_as_the_popup(self, con_name): + con = CONNECTIONS[con_name] + for field, combo_text, value in CASES: + from_popup = prompt_utils.get_combo_operator(field, combo_text, con) + from_cli = operands.get_operator(field, value, con) + assert from_popup == from_cli, \ + "%s / %s: pop-up %r, cli %r" % (con_name, field, from_popup, from_cli) + + def test_the_candidate_list_agrees_with_the_popup(self): + """what the terminal offers must be what the pop-up would have built.""" + con = CONNECTIONS["curl"] + + wildcard = [c for c in operands.candidates(con) + if c["type"] == "regexp" and c["operand"] == "dest.host"][0] + from_popup = prompt_utils.get_combo_operator( + operands.FIELD_REGEX_HOST, "to *.github.com", con) + assert (wildcard["type"], wildcard["operand"], wildcard["data"]) == from_popup diff --git a/ui/tests/cli/test_policy.py b/ui/tests/cli/test_policy.py new file mode 100644 index 0000000000..57cb6f4cdd --- /dev/null +++ b/ui/tests/cli/test_policy.py @@ -0,0 +1,121 @@ +# +# pytest -v cli/test_policy.py +# + +from opensnitch.cli import policy, rules +from opensnitch.cli.proto import ui_pb2 +from opensnitch.rule_consts import RuleConsts + + +def make_connection(**kwargs): + fields = dict(protocol="tcp", dst_ip="140.82.121.6", dst_host="api.github.com", + dst_port=443, user_id=1000, process_id=41233, + process_path="/usr/bin/curl", process_args=["/usr/bin/curl"]) + fields.update(kwargs) + return ui_pb2.Connection(**fields) + + +class TestSignature: + + def test_same_connection_same_signature(self): + assert policy.signature("n", make_connection()) == \ + policy.signature("n", make_connection()) + + def test_ignores_pid(self): + """the pid changes on every run, it must not create a new queue entry.""" + assert policy.signature("n", make_connection(process_id=1)) == \ + policy.signature("n", make_connection(process_id=99999)) + + def test_ignores_address_when_the_host_is_known(self): + """a CDN answers with a different address every time.""" + assert policy.signature("n", make_connection(dst_ip="1.1.1.1")) == \ + policy.signature("n", make_connection(dst_ip="2.2.2.2")) + + def test_destination_matters(self): + assert policy.signature("n", make_connection(dst_host="a.com")) != \ + policy.signature("n", make_connection(dst_host="b.com")) + + def test_process_matters(self): + assert policy.signature("n", make_connection(process_path="/bin/a")) != \ + policy.signature("n", make_connection(process_path="/bin/b")) + + def test_node_matters(self): + assert policy.signature("a", make_connection()) != \ + policy.signature("b", make_connection()) + + +class TestProvisionalRule: + + def test_scoped_to_process_and_destination(self): + con = make_connection() + rule = policy.build_provisional(con, "abcdef123456", "allow", "1h") + + assert rule.action == "allow" + assert rule.duration == "1h" + assert rule.name.startswith(policy.PROVISIONAL_PREFIX) + assert rule.operator.type == RuleConsts.RULE_TYPE_LIST + + got = [(o.operand, o.data) for o in rule.operator.list] + assert (RuleConsts.OPERAND_PROCESS_PATH, "/usr/bin/curl") in got + assert (RuleConsts.OPERAND_DEST_HOST, "api.github.com") in got + assert (RuleConsts.OPERAND_DEST_PORT, "443") in got + + def test_uses_the_address_when_there_is_no_host(self): + con = make_connection(dst_host="") + rule = policy.build_provisional(con, "abcdef123456", "allow", "1h") + got = [(o.operand, o.data) for o in rule.operator.list] + assert (RuleConsts.OPERAND_DEST_IP, "140.82.121.6") in got + + def test_the_daemon_would_accept_it(self): + rule = policy.build_provisional(make_connection(), "abcdef123456", "allow", "1h") + assert rules.validate_rule(rule) is None + + def test_nothing_to_match_on(self): + """rather than a rule that matches everything, let the daemon decide.""" + con = ui_pb2.Connection(protocol="tcp", dst_port=0) + assert policy.build_provisional(con, "sig", "allow", "1h") is None + + +class TestPolicy: + + def test_answers_and_queues(self, db, config, connection): + p = policy.Policy(db, config) + rule = p.on_ask("unix:/local", connection) + + assert rule is not None + assert rule.action == "allow" + assert db.pending_count() == 1 + + def test_repeats_count_instead_of_duplicating(self, db, config, connection): + p = policy.Policy(db, config) + p.on_ask("unix:/local", connection) + p.on_ask("unix:/local", connection) + + assert db.pending_count() == 1 + assert db.pending()[0]["hits"] == 2 + + def test_records_the_provisional_rule(self, db, config, connection): + p = policy.Policy(db, config) + rule = p.on_ask("unix:/local", connection) + entry = db.pending()[0] + + assert entry["provisional_name"] == rule.name + assert entry["provisional_expires"] is not None + + def test_full_queue_still_answers(self, db, config, connection): + config._parser.set("policy", "queue_max", "1") + p = policy.Policy(db, config) + p.on_ask("unix:/local", connection) + + other = ui_pb2.Connection(protocol="tcp", dst_ip="1.2.3.4", dst_port=80, + process_path="/bin/wget", process_args=["/bin/wget"]) + rule = p.on_ask("unix:/local", other) + + # the packet is waiting, answering matters more than recording + assert rule is not None + assert db.pending_count() == 1 + assert p.dropped == 1 + + def test_never_raises(self, db, config): + p = policy.Policy(db, config) + assert p.on_ask("unix:/local", object()) is None diff --git a/ui/tests/cli/test_review.py b/ui/tests/cli/test_review.py new file mode 100644 index 0000000000..ec3f9e3361 --- /dev/null +++ b/ui/tests/cli/test_review.py @@ -0,0 +1,200 @@ +# +# pytest -v cli/test_review.py +# + +import json + +from opensnitch.cli import db as dbmod, review +from opensnitch.cli.proto import ui_pb2 + + +def scripted(answers): + """feeds the review loop a fixed list of answers.""" + it = iter(answers) + + def read(prompt): + try: + return next(it) + except StopIteration: + return "q" + return read + + +def silent(*args, **kwargs): + pass + + +def queue_one(db, connection, provisional="cli-auto-abc"): + db.record_pending("unix:/local", "sig", connection, + {"name": provisional, "action": "allow", "duration": "1h", + "expires_in": 3600}) + return db.pending()[0] + + +def sent_rules(db): + """the notifications waiting for the service to pick up.""" + out = [] + for row in db.queued_notifications(): + out.append((row["ntf_type"], json.loads(row["rule_json"]))) + return out + + +class TestReviewLoop: + + def test_accepting_creates_an_allow_rule(self, db, config, connection): + queue_one(db, connection) + applied = review.review_loop(db, db.pending(), config, + read=scripted(["y"]), write=silent) + + assert applied == 1 + types = [t for t, _ in sent_rules(db)] + # the temporary rule is removed first, then the real one is installed + assert types == [ui_pb2.DELETE_RULE, ui_pb2.CHANGE_RULE] + + _, rule = sent_rules(db)[1] + assert rule["action"] == "allow" + assert rule["duration"] == "always" + + def test_n_denies_the_same_match(self, db, config, connection): + queue_one(db, connection) + review.review_loop(db, db.pending(), config, read=scripted(["n"]), write=silent) + + _, rule = sent_rules(db)[1] + assert rule["action"] == "deny" + + def test_r_rejects(self, db, config, connection): + queue_one(db, connection) + review.review_loop(db, db.pending(), config, read=scripted(["r"]), write=silent) + + _, rule = sent_rules(db)[1] + assert rule["action"] == "reject" + + def test_the_entry_leaves_the_queue(self, db, config, connection): + queue_one(db, connection) + review.review_loop(db, db.pending(), config, read=scripted(["y"]), write=silent) + assert db.pending_count() == 0 + + def test_skipping_leaves_it_alone(self, db, config, connection): + queue_one(db, connection) + applied = review.review_loop(db, db.pending(), config, + read=scripted(["s"]), write=silent) + + assert applied == 0 + assert db.pending_count() == 1 + assert sent_rules(db) == [] + + def test_dropping_creates_no_rule(self, db, config, connection): + queue_one(db, connection) + review.review_loop(db, db.pending(), config, read=scripted(["d"]), write=silent) + + assert db.pending_count() == 0 + assert sent_rules(db) == [] + + def test_quitting_stops(self, db, config, connection): + queue_one(db, connection) + assert review.review_loop(db, db.pending(), config, + read=scripted(["q"]), write=silent) == 0 + + def test_empty_answer_does_nothing(self, db, config, connection): + """like git add -p: no destructive default.""" + queue_one(db, connection) + review.review_loop(db, db.pending(), config, read=scripted(["", "", "s"]), + write=silent) + assert db.pending_count() == 1 + + def test_no_provisional_rule_means_no_delete(self, db, config, connection): + db.record_pending("unix:/local", "sig", connection, {}) + review.review_loop(db, db.pending(), config, read=scripted(["y"]), write=silent) + + assert [t for t, _ in sent_rules(db)] == [ui_pb2.CHANGE_RULE] + + +class TestEditing: + + def test_switch_to_the_host_wildcard_and_deny_forever(self, db, config, connection): + """the case the whole thing exists for: narrow the rule by hand.""" + queue_one(db, connection) + + # e -> match on -> the *.github.com wildcard -> action -> deny -> apply + answers = ["e", "1", "4", "2", "2", "a"] + review.review_loop(db, db.pending(), config, read=scripted(answers), write=silent) + + _, rule = sent_rules(db)[1] + assert rule["action"] == "deny" + assert rule["operator"]["type"] == "regexp" + assert rule["operator"]["operand"] == "dest.host" + assert rule["operator"]["data"] == r"^(|.*\.)github\.com$" + + def test_switch_to_the_command_line(self, db, config, connection): + queue_one(db, connection) + answers = ["e", "1", "2", "a"] + review.review_loop(db, db.pending(), config, read=scripted(answers), write=silent) + + _, rule = sent_rules(db)[1] + assert rule["operator"]["operand"] == "process.command" + + def test_change_the_duration(self, db, config, connection): + queue_one(db, connection) + # e -> duration -> 1h (entry 5) -> apply + answers = ["e", "3", "5", "a"] + review.review_loop(db, db.pending(), config, read=scripted(answers), write=silent) + + _, rule = sent_rules(db)[1] + assert rule["duration"] == "1h" + + def test_adding_a_condition_makes_a_list_rule(self, db, config, connection): + queue_one(db, connection) + # e -> also require -> first extra candidate -> apply + answers = ["e", "5", "1", "a"] + review.review_loop(db, db.pending(), config, read=scripted(answers), write=silent) + + _, rule = sent_rules(db)[1] + assert rule["operator"]["type"] == "list" + assert len(rule["operator"]["list"]) == 2 + + def test_cancelling_the_editor_goes_back(self, db, config, connection): + queue_one(db, connection) + answers = ["e", "c", "s"] + applied = review.review_loop(db, db.pending(), config, + read=scripted(answers), write=silent) + assert applied == 0 + assert db.pending_count() == 1 + + def test_a_custom_regexp_go_cannot_compile_is_refused(self, db, config, connection): + queue_one(db, connection) + written = [] + # e -> match on -> custom -> regexp / dest.host / lookahead, then give up + answers = ["e", "1", "c", "regexp", "dest.host", "^(?=x)", "c", "s"] + review.review_loop(db, db.pending(), config, read=scripted(answers), + write=written.append) + + assert any("RE2" in line for line in written) + assert sent_rules(db) == [] + + +class TestRendering: + + def test_shows_the_process_destination_and_provisional_rule(self, db, config, connection): + entry = queue_one(db, connection) + written = [] + review.render_entry(entry, review.entry_connection(entry), 1, 1, written.append) + text = "\n".join(written) + + assert "/usr/bin/curl" in text + assert "api.github.com" in text + assert "443" in text + assert "cli-auto-abc" in text + + def test_details_include_the_command_line(self, db, config, connection): + entry = queue_one(db, connection) + written = [] + review.render_details(entry, review.entry_connection(entry), written.append) + assert "https://api.github.com/repos" in "\n".join(written) + + def test_the_connection_is_rebuilt_from_the_queue(self, db, config, connection): + entry = queue_one(db, connection) + con = review.entry_connection(entry) + + assert con.process_path == connection.process_path + assert list(con.process_args) == list(connection.process_args) + assert con.dst_host == connection.dst_host diff --git a/ui/tests/cli/test_rules.py b/ui/tests/cli/test_rules.py new file mode 100644 index 0000000000..0bbeff6253 --- /dev/null +++ b/ui/tests/cli/test_rules.py @@ -0,0 +1,130 @@ +# +# pytest -v cli/test_rules.py +# + +import pytest + +from opensnitch.cli import durations, rules +from opensnitch.rule_consts import RuleConsts + + +class TestDurations: + + @pytest.mark.parametrize("value", ["30s", "5m", "1h", "12h", "1h30m", "always", + "until restart", "once"]) + def test_accepted(self, value): + assert durations.validate(value) is None + + @pytest.mark.parametrize("value", ["1d", "1w", "banana", "", "1", "-5m"]) + def test_refused(self, value): + """Go's time.ParseDuration has no days or weeks. + + The daemon throws away the parsing error, so a rule with an unparseable + duration is never scheduled for removal and lives until it restarts. + """ + assert durations.validate(value) is not None + + def test_to_seconds(self): + assert durations.to_seconds("1h30m") == 5400 + assert durations.to_seconds("30s") == 30 + assert durations.to_seconds("always") is None + + def test_is_temporary(self): + assert durations.is_temporary("1h") + assert not durations.is_temporary("always") + assert not durations.is_temporary("until restart") + # the daemon does not schedule "once" rules for removal either + assert not durations.is_temporary("once") + + +class TestBuildRule: + + def test_single_operand_is_flattened(self): + op = rules.new_operator("simple", "process.path", "/usr/bin/curl") + rule = rules.build_rule("r", "allow", "always", [op]) + + assert rule.operator.type == "simple" + assert rule.operator.data == "/usr/bin/curl" + assert len(rule.operator.list) == 0 + + def test_several_operands_become_a_list(self): + ops = [rules.new_operator("simple", "process.path", "/usr/bin/curl"), + rules.new_operator("simple", "dest.port", "443")] + rule = rules.build_rule("r", "allow", "always", ops) + + assert rule.operator.type == RuleConsts.RULE_TYPE_LIST + assert rule.operator.operand == RuleConsts.RULE_TYPE_LIST + # the daemon reads the operands from the list, not from data + assert rule.operator.data == "" + assert len(rule.operator.list) == 2 + + def test_name_matches_the_popup_convention(self): + assert rules.rule_name("allow", "always", False, "/usr/bin/curl") == \ + "allow-always-simple-usr-bin-curl" + + def test_unique_name_avoids_a_rename_by_the_daemon(self): + taken = {"allow-always-simple-x", "allow-always-simple-x-2"} + assert rules.unique_name("allow-always-simple-x", taken) == "allow-always-simple-x-3" + + +class TestValidation: + + def test_accepts_a_normal_rule(self): + op = rules.new_operator("simple", "process.path", "/usr/bin/curl") + assert rules.validate_rule(rules.build_rule("r", "allow", "always", [op])) is None + + def test_refuses_once(self): + """a "once" rule sent over the notifications channel never expires.""" + op = rules.new_operator("simple", "process.path", "/x") + error = rules.validate_rule(rules.build_rule("r", "allow", "once", [op])) + assert error is not None and "once" in error + + def test_refuses_days(self): + op = rules.new_operator("simple", "process.path", "/x") + assert rules.validate_rule(rules.build_rule("r", "allow", "2d", [op])) is not None + + def test_refuses_an_unknown_action(self): + op = rules.new_operator("simple", "process.path", "/x") + assert rules.validate_rule(rules.build_rule("r", "explode", "always", [op])) is not None + + def test_network_type_needs_the_network_operand(self): + bad = rules.new_operator("network", "dest.ip", "10.0.0.0/8") + assert rules.validate_operator(bad) is not None + good = rules.new_operator("network", "dest.network", "10.0.0.0/8") + assert rules.validate_operator(good) is None + + def test_empty_data_only_allowed_for_some_types(self): + # simple and regexp may match an empty string, network may not + assert rules.validate_operator(rules.new_operator("simple", "dest.host", "")) is None + assert rules.validate_operator(rules.new_operator("regexp", "dest.host", "")) is None + assert rules.validate_operator( + rules.new_operator("network", "dest.network", "")) is not None + + @pytest.mark.parametrize("pattern", [r"^(?=x)foo$", r"(?!x)", r"(?<=a)b", r"(a)\1"]) + def test_refuses_patterns_go_cannot_compile(self, pattern): + """Go uses RE2: no lookaround, no backreferences.""" + op = rules.new_operator("regexp", "dest.host", pattern) + assert rules.validate_operator(op) is not None + + def test_accepts_the_wildcard_the_popup_builds(self): + op = rules.new_operator("regexp", "dest.host", r"^(|.*\.)github\.com$") + assert rules.validate_operator(op) is None + + def test_refuses_a_broken_pattern(self): + assert rules.validate_operator(rules.new_operator("regexp", "dest.host", "[")) is not None + + +class TestWarnings: + + def test_upper_case_regexp_would_never_match(self): + """the daemon lowercases a non case sensitive pattern before compiling.""" + op = rules.new_operator("regexp", "dest.host", r"^GitHub\.com$") + assert rules.case_warning(op) is not None + + def test_no_warning_when_case_sensitive(self): + op = rules.new_operator("regexp", "dest.host", r"^GitHub\.com$", sensitive=True) + assert rules.case_warning(op) is None + + def test_no_warning_for_lower_case(self): + op = rules.new_operator("regexp", "dest.host", r"^github\.com$") + assert rules.case_warning(op) is None diff --git a/ui/tests/cli/test_service.py b/ui/tests/cli/test_service.py new file mode 100644 index 0000000000..43a60c64f9 --- /dev/null +++ b/ui/tests/cli/test_service.py @@ -0,0 +1,199 @@ +# +# pytest -v cli/test_service.py +# + +import json +import threading +import time + +from opensnitch.cli import policy +from opensnitch.cli.proto import ui_pb2 +from opensnitch.cli.service import Service, CLOSE_STREAM + + +class FakeContext: + """the parts of a grpc context the service uses.""" + + def __init__(self, peer="unix:"): + self._peer = peer + self.callbacks = [] + + def peer(self): + return self._peer + + def add_callback(self, callback): + self.callbacks.append(callback) + + def cancel(self): + for callback in self.callbacks: + callback() + + +def make_service(db, config): + return Service(db, policy.Policy(db, config), config) + + +def client_config(default_action="deny"): + return ui_pb2.ClientConfig( + id=1, name="testnode", version="6.0", + config=json.dumps({"DefaultAction": default_action, "InterceptUnknown": False}), + rules=[]) + + +class TestPing: + + def test_echoes_the_id(self, db, config): + """the daemon drops the answer if the id doesn't come back.""" + service = make_service(db, config) + reply = service.Ping(ui_pb2.PingRequest(id=987654), FakeContext()) + assert reply.id == 987654 + + +class TestPeerAddress: + + def test_unix_socket_has_no_address(self, db, config): + service = make_service(db, config) + assert service.peer_addr("unix:") == "unix:/local" + + def test_tcp(self, db, config): + service = make_service(db, config) + assert service.peer_addr("ipv4:192.168.1.5:12345") == "ipv4:192.168.1.5:12345" + + +class TestSubscribe: + + def test_registers_the_node_before_returning(self, db, config): + """Notifications is refused for a node that isn't registered yet.""" + service = make_service(db, config) + service.Subscribe(client_config(), FakeContext()) + + assert service.get_node("unix:/local") is not None + assert len(db.nodes()) == 1 + + def test_overrides_the_default_action(self, db, config): + """what the daemon does while we're busy answering another connection.""" + service = make_service(db, config) + reply = service.Subscribe(client_config(default_action="deny"), FakeContext()) + + assert json.loads(reply.config)["DefaultAction"] == "allow" + + def test_leaves_a_config_it_cannot_read_alone(self, db, config): + service = make_service(db, config) + broken = ui_pb2.ClientConfig(id=1, name="n", version="6.0", config="not json") + assert service.Subscribe(broken, FakeContext()).config == "not json" + + def test_stores_the_rules_of_the_node(self, db, config): + service = make_service(db, config) + node_config = client_config() + rule = node_config.rules.add() + rule.name = "000-allow-localhost" + rule.action = "allow" + rule.duration = "always" + rule.enabled = True + service.Subscribe(node_config, FakeContext()) + + assert "000-allow-localhost" in db.rule_names("unix:/local") + + +class TestAskRule: + + def test_answers_and_queues(self, db, config, connection): + service = make_service(db, config) + service.Subscribe(client_config(), FakeContext()) + + rule = service.AskRule(connection, FakeContext()) + + assert rule is not None + assert rule.action == "allow" + assert db.pending_count() == 1 + + def test_answers_quickly(self, db, config, connection): + """the daemon is holding the packet, and gives up after two minutes.""" + service = make_service(db, config) + service.Subscribe(client_config(), FakeContext()) + + start = time.time() + service.AskRule(connection, FakeContext()) + assert time.time() - start < 1.0 + + def test_deny_policy(self, db, config, connection): + config._parser.set("policy", "unreviewed_action", "deny") + service = make_service(db, config) + assert service.AskRule(connection, FakeContext()).action == "deny" + + +def open_replies(stop): + """a reply stream that stays open, the way a connected daemon's does. + + An iterator that is already exhausted means the daemon hung up, and the + service closes the notification stream when that happens. + """ + def generator(): + while not stop.wait(0.01): + pass + return + yield # pragma: no cover + return generator() + + +class TestNotifications: + + def test_refuses_an_unknown_node(self, db, config): + service = make_service(db, config) + assert list(service.Notifications(iter([]), FakeContext())) == [] + + def test_delivers_what_the_outbox_puts_on_the_queue(self, db, config): + service = make_service(db, config) + service.Subscribe(client_config(), FakeContext()) + node = service.get_node("unix:/local") + + node.queue.put(ui_pb2.Notification(id=1, type=ui_pb2.CHANGE_RULE)) + node.queue.put(ui_pb2.Notification(id=0, type=CLOSE_STREAM)) + + stop = threading.Event() + got = list(service.Notifications(open_replies(stop), FakeContext())) + stop.set() + + assert len(got) == 1 + assert got[0].id == 1 + + def test_stops_when_the_daemon_hangs_up(self, db, config): + service = make_service(db, config) + service.Subscribe(client_config(), FakeContext()) + assert list(service.Notifications(iter([]), FakeContext())) == [] + + def test_marks_the_node_offline_when_it_disconnects(self, db, config): + service = make_service(db, config) + service.Subscribe(client_config(), FakeContext()) + context = FakeContext() + + stream = service.Notifications(iter([]), context) + node = service.get_node("unix:/local") + node.queue.put(ui_pb2.Notification(id=0, type=CLOSE_STREAM)) + list(stream) + context.cancel() + + assert db.nodes()[0]["online"] == 0 + + def test_records_what_the_daemon_answered(self, db, config): + service = make_service(db, config) + service.Subscribe(client_config(), FakeContext()) + + outbox_id = db.queue_notification("unix:/local", ui_pb2.CHANGE_RULE, "{}") + db.mark_sent(outbox_id, 42) + + replies = [ui_pb2.NotificationReply(id=0, code=ui_pb2.OK), + ui_pb2.NotificationReply(id=42, code=ui_pb2.ERROR, data="bad regexp")] + service._read_replies(service.get_node("unix:/local"), iter(replies)) + + row = db.get_outbox(outbox_id) + assert row["state"] == "error" + assert row["last_error"] == "bad regexp" + + +class TestPostAlert: + + def test_always_answers(self, db, config): + service = make_service(db, config) + alert = ui_pb2.Alert(id=1) + assert service.PostAlert(alert, FakeContext()).id == 0 From a6ba2e5bab6ec17b8794a94f091fa4806fcf94f2 Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 20:41:47 -0700 Subject: [PATCH 10/24] cli: deny unreviewed connections by default A connection nobody has approved is now denied rather than allowed while it waits in the review queue, so a server does not let anything out that has not been through the queue. policy.default_action follows it, since that is what the daemon applies while it is already waiting for an answer about another connection: leaving it on allow would let a burst of new connections partly through and defeat denying them one at a time. It also matches the graphical interface, whose own default for that setting is deny. policy.unreviewed_action = allow inverts this for operators who would rather a server keep working while the queue waits for them. The connection is recorded for review either way. Co-Authored-By: Claude Opus 5 --- ui/opensnitch/cli/config.py | 4 ++-- ui/opensnitch/cli/server.py | 10 +++++++--- ui/resources/cli.conf.example | 33 ++++++++++++++++++++++++--------- ui/tests/cli/test_policy.py | 19 +++++++++++++++++++ ui/tests/cli/test_service.py | 22 +++++++++++++++++----- 5 files changed, 69 insertions(+), 19 deletions(-) diff --git a/ui/opensnitch/cli/config.py b/ui/opensnitch/cli/config.py index 4939d65f75..cf721c3d37 100644 --- a/ui/opensnitch/cli/config.py +++ b/ui/opensnitch/cli/config.py @@ -47,9 +47,9 @@ "max_message_length": "4194304", }, "policy": { - "unreviewed_action": "allow", + "unreviewed_action": "deny", "unreviewed_duration": "1h", - "default_action": "allow", + "default_action": "deny", "queue_max": "1000", }, "db": { diff --git a/ui/opensnitch/cli/server.py b/ui/opensnitch/cli/server.py index b8abebf5c1..b80c07548a 100644 --- a/ui/opensnitch/cli/server.py +++ b/ui/opensnitch/cli/server.py @@ -32,6 +32,7 @@ from google.protobuf import json_format from opensnitch import auth +from opensnitch.rule_consts import RuleConsts from opensnitch.cli.proto import ui_pb2, ui_pb2_grpc from opensnitch.cli import db as dbmod from opensnitch.cli.policy import Policy @@ -143,9 +144,12 @@ def start(self): logger.info("listening on %s (auth: %s)", address, auth_type) logger.info("connections nobody has reviewed yet: %s for %s", self._policy.action, self._policy.duration) - if self._policy.action == "allow": - logger.warning("unreviewed connections are ALLOWED. Set policy.unreviewed_action " - "to deny if this machine should block them instead") + if self._policy.action == RuleConsts.ACTION_ALLOW: + logger.warning("unreviewed connections are ALLOWED until you review them. " + "Set policy.unreviewed_action to deny to block them instead") + else: + logger.info("unreviewed connections are blocked. Run 'opensnitch-cli review' " + "to go through them") return port def _start_thread(self, target, name): diff --git a/ui/resources/cli.conf.example b/ui/resources/cli.conf.example index ceea2ee98a..9f8760d432 100644 --- a/ui/resources/cli.conf.example +++ b/ui/resources/cli.conf.example @@ -30,22 +30,37 @@ max_message_length = 4194304 [policy] # What to answer for a connection nobody has reviewed yet: allow, deny or reject. # -# The default is allow, so a server keeps working while the queue waits for you. -# The connection is still recorded for review, and the decision you take governs -# every later connection. Set this to deny if this machine should block anything -# that has not been approved. -unreviewed_action = allow +# The default is deny: nothing this machine has not been approved to do gets +# out. The connection is still recorded, and 'opensnitch-cli review' is how you +# approve it; the decision you take governs every later connection. +# +# Set this to allow if you would rather the server keep working while the queue +# waits for you. That is the weaker setting, but it never breaks a service you +# forgot to approve, and the connection is still queued either way. +# +# reject behaves like deny but answers the connection instead of dropping it, +# so the program fails immediately rather than hanging until it times out. +unreviewed_action = deny # How long that answer lasts before the connection is asked about again. # Long enough not to ask about every packet, short enough that a connection you -# never review comes back rather than being allowed for good. +# never review comes back rather than being decided for good. # Days and weeks are not accepted: the daemon parses this with Go's # time.ParseDuration, which only knows ns, us, ms, s, m and h. unreviewed_duration = 1h -# What the daemon should do while it is waiting for an answer to another -# connection, or if this program stops responding. allow or deny. -default_action = allow +# What the daemon should do on its own, without asking: while it is already +# waiting for an answer about another connection, or if this program stops +# responding. allow or deny. +# +# Keep this the same as unreviewed_action. Leaving it on allow while +# unreviewed_action is deny means a burst of new connections partly gets +# through, which defeats the point of denying them one at a time. +# +# This is pushed to the daemon when it connects and is not written to its +# configuration file, so it only applies while opensnitch-cli is running. With +# nothing connected the daemon falls back to its own DefaultAction. +default_action = deny # Stop recording new connections past this many unreviewed entries. Connections # are still answered, they just stop being added to the queue; opensnitch-cli diff --git a/ui/tests/cli/test_policy.py b/ui/tests/cli/test_policy.py index 57cb6f4cdd..9f0130b498 100644 --- a/ui/tests/cli/test_policy.py +++ b/ui/tests/cli/test_policy.py @@ -83,7 +83,26 @@ def test_answers_and_queues(self, db, config, connection): rule = p.on_ask("unix:/local", connection) assert rule is not None + assert db.pending_count() == 1 + + def test_unreviewed_connections_are_denied_by_default(self, db, config, connection): + """nothing gets out until it has been approved. + + This is the setting that decides whether the machine is fail-closed, so + pin the default rather than leaving it to the configuration file. + """ + assert config.get("policy", "unreviewed_action") == "deny" + + p = policy.Policy(db, config) + assert p.on_ask("unix:/local", connection).action == "deny" + + def test_can_be_made_fail_open(self, db, config, connection): + config._parser.set("policy", "unreviewed_action", "allow") + p = policy.Policy(db, config) + + rule = p.on_ask("unix:/local", connection) assert rule.action == "allow" + # still queued: allowing it for now is not the same as approving it assert db.pending_count() == 1 def test_repeats_count_instead_of_duplicating(self, db, config, connection): diff --git a/ui/tests/cli/test_service.py b/ui/tests/cli/test_service.py index 43a60c64f9..85727e9bd1 100644 --- a/ui/tests/cli/test_service.py +++ b/ui/tests/cli/test_service.py @@ -71,7 +71,19 @@ def test_registers_the_node_before_returning(self, db, config): assert len(db.nodes()) == 1 def test_overrides_the_default_action(self, db, config): - """what the daemon does while we're busy answering another connection.""" + """what the daemon does while we're busy answering another connection. + + Denying by default here matters as much as denying an unreviewed + connection: without it a burst of new connections partly gets through + while we're answering the first one. + """ + service = make_service(db, config) + reply = service.Subscribe(client_config(default_action="allow"), FakeContext()) + + assert json.loads(reply.config)["DefaultAction"] == "deny" + + def test_the_default_action_follows_the_configuration(self, db, config): + config._parser.set("policy", "default_action", "allow") service = make_service(db, config) reply = service.Subscribe(client_config(default_action="deny"), FakeContext()) @@ -104,7 +116,7 @@ def test_answers_and_queues(self, db, config, connection): rule = service.AskRule(connection, FakeContext()) assert rule is not None - assert rule.action == "allow" + assert rule.action == "deny" assert db.pending_count() == 1 def test_answers_quickly(self, db, config, connection): @@ -116,10 +128,10 @@ def test_answers_quickly(self, db, config, connection): service.AskRule(connection, FakeContext()) assert time.time() - start < 1.0 - def test_deny_policy(self, db, config, connection): - config._parser.set("policy", "unreviewed_action", "deny") + def test_allow_policy(self, db, config, connection): + config._parser.set("policy", "unreviewed_action", "allow") service = make_service(db, config) - assert service.AskRule(connection, FakeContext()).action == "deny" + assert service.AskRule(connection, FakeContext()).action == "allow" def open_replies(stop): From 09ab28f550234602aa0e16225e1adcaf64764b28 Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 20:50:17 -0700 Subject: [PATCH 11/24] cli: match the executable too when the command line can't be trusted A program chooses its own argv[0], so a rule matching only on the command line can be fooled by anything that sets it to look like something else. When the command line does not start with an absolute path, or starts under /proc (/proc/self/fd/... and friends), pin the executable as well and say why. This is what the pop-up already does in dialogs/prompt/dialog.py; without it a rule made from a terminal was weaker than the same rule made from the graphical interface. The rule that is about to be sent is now printed with its operands and warnings when it is applied, not only when the entry is first shown, so an edited rule doesn't go out unseen. Also offer the binary's md5 as something to match on when the daemon sent one, which the pop-up's checksum checkbox does and this did not. Co-Authored-By: Claude Opus 5 --- ui/opensnitch/cli/review.py | 42 +++++++++++++++++--- ui/opensnitch/operands.py | 6 +++ ui/tests/cli/test_review.py | 77 ++++++++++++++++++++++++++++++++++++- 3 files changed, 119 insertions(+), 6 deletions(-) diff --git a/ui/opensnitch/cli/review.py b/ui/opensnitch/cli/review.py index 2f9e4e617c..d68f28a4ae 100644 --- a/ui/opensnitch/cli/review.py +++ b/ui/opensnitch/cli/review.py @@ -22,6 +22,7 @@ """ import json +import os import time from opensnitch import operands @@ -96,6 +97,23 @@ def __init__(self, entry, con, default_action, default_duration, taken_names): self.selected = self.candidates[0] if len(self.candidates) > 0 else None self.extra = [] + def untrusted_command(self): + """whether matching on the command line alone could be fooled. + + A program chooses its own argv[0], so a command line that doesn't start + with an absolute path, or that starts under /proc (/proc/self/fd/...), + says nothing about which binary is really running. The pop-up pins the + executable as well in that case, and so do we. + """ + if self.selected is None: + return False + if self.selected["operand"] != RuleConsts.OPERAND_PROCESS_COMMAND: + return False + argv = " ".join(self.con.process_args).split(" ") + if len(argv) == 0 or argv[0] == "": + return True + return not os.path.isabs(argv[0]) or argv[0].startswith("/proc") + def operators(self): ops = [] if self.selected is not None: @@ -103,6 +121,13 @@ def operators(self): self.selected["data"])) for cand in self.extra: ops.append(rules.new_operator(cand["type"], cand["operand"], cand["data"])) + + if self.untrusted_command() and self.con.process_path != "": + already = [o for o in ops if o.operand == RuleConsts.OPERAND_PROCESS_PATH] + if len(already) == 0: + ops.append(rules.new_operator(RuleConsts.RULE_TYPE_SIMPLE, + RuleConsts.OPERAND_PROCESS_PATH, + self.con.process_path)) return ops @property @@ -135,6 +160,9 @@ def validate(self): def warnings(self): found = [] + if self.untrusted_command(): + found.append("a program picks its own command line, so the executable is " + "matched as well, otherwise this rule could be fooled") for op in self.operators(): warning = rules.case_warning(op) if warning is not None: @@ -274,13 +302,10 @@ def apply_decision(db, entry, rule): db.queue_notification(entry["node"], ui_pb2.CHANGE_RULE, json_format.MessageToJson(rule), pending_id=entry["id"]) - db.set_pending_state(entry["id"], db_state_decided(), json_format.MessageToJson(rule)) - - -def db_state_decided(): from opensnitch.cli import db as dbmod - return dbmod.STATE_DECIDED + db.set_pending_state(entry["id"], dbmod.STATE_DECIDED, + json_format.MessageToJson(rule)) def review_loop(db, entries, config, read=input, write=print): @@ -333,10 +358,17 @@ def review_loop(db, entries, config, read=input, write=print): write(" the daemon would refuse this rule: %s" % error) continue + # the rule may have been edited since it was last shown, so say what + # is actually being sent, warnings included + for warning in decision.warnings(): + write(" ! %s" % warning) + rule = decision.build() apply_decision(db, entry, rule) applied += 1 write(" queued: %s %s as '%s'" % (rule.action, rule.duration, rule.name)) + for line in rules.describe_rule(rule).split("\n")[1:]: + write(" %s" % line) break return applied diff --git a/ui/opensnitch/operands.py b/ui/opensnitch/operands.py index 22062fadb8..e7de05efdc 100644 --- a/ui/opensnitch/operands.py +++ b/ui/opensnitch/operands.py @@ -238,6 +238,12 @@ def add(label, triple): if len(con.process_args) > 0 and con.process_args[0] != "": add("this command line", from_process_command(con.process_args, con.process_path)) + checksums = dict(getattr(con, "process_checksums", None) or {}) + md5 = checksums.get(RuleConsts.OPERAND_PROCESS_HASH_MD5) + if md5: + add("this exact binary, by checksum", + (RuleConsts.RULE_TYPE_SIMPLE, RuleConsts.OPERAND_PROCESS_HASH_MD5, md5)) + if con.dst_host != "" and con.dst_host != con.dst_ip: add("this host", from_dest_host(con.dst_host)) for domain in dest_host_wildcards(con.dst_host): diff --git a/ui/tests/cli/test_review.py b/ui/tests/cli/test_review.py index ec3f9e3361..f71aed911f 100644 --- a/ui/tests/cli/test_review.py +++ b/ui/tests/cli/test_review.py @@ -4,7 +4,8 @@ import json -from opensnitch.cli import db as dbmod, review +from opensnitch import operands +from opensnitch.cli import review from opensnitch.cli.proto import ui_pb2 @@ -172,6 +173,80 @@ def test_a_custom_regexp_go_cannot_compile_is_refused(self, db, config, connecti assert sent_rules(db) == [] +class TestUntrustedCommandLine: + """a program chooses its own argv[0], so matching on it alone is spoofable. + + The pop-up pins the executable too in that case (dialogs/prompt/dialog.py); + the terminal client has to behave the same or its rules are weaker. + """ + + def _decide_on_command_line(self, db, config, con): + db.record_pending("unix:/local", "sig", con, {}) + review.review_loop(db, db.pending(), config, + read=scripted(["e", "1", "2", "a"]), write=silent) + return sent_rules(db)[-1][1] + + def test_relative_argv0_also_pins_the_executable(self, db, config): + con = ui_pb2.Connection(protocol="tcp", dst_ip="1.2.3.4", dst_port=443, + process_path="/usr/bin/curl", + process_args=["curl", "https://example.com"]) + rule = self._decide_on_command_line(db, config, con) + + assert rule["operator"]["type"] == "list" + operands_used = [(o["operand"], o["data"]) for o in rule["operator"]["list"]] + assert ("process.path", "/usr/bin/curl") in operands_used + assert any(o == "process.command" for o, _ in operands_used) + + def test_proc_self_fd_also_pins_the_executable(self, db, config): + con = ui_pb2.Connection(protocol="tcp", dst_ip="1.2.3.4", dst_port=443, + process_path="/usr/bin/python3", + process_args=["/proc/self/fd/3", "script.py"]) + rule = self._decide_on_command_line(db, config, con) + + assert rule["operator"]["type"] == "list" + operands_used = [o["operand"] for o in rule["operator"]["list"]] + assert "process.path" in operands_used + + def test_an_absolute_command_line_is_left_alone(self, db, config, connection): + rule = self._decide_on_command_line(db, config, connection) + + # /usr/bin/curl -sSL ... is trustworthy on its own + assert rule["operator"]["type"] == "simple" + assert rule["operator"]["operand"] == "process.command" + + def test_the_user_is_told_why(self, db, config): + con = ui_pb2.Connection(protocol="tcp", dst_ip="1.2.3.4", dst_port=443, + process_path="/usr/bin/curl", + process_args=["curl", "https://example.com"]) + db.record_pending("unix:/local", "sig", con, {}) + written = [] + review.review_loop(db, db.pending(), config, + read=scripted(["e", "1", "2", "a"]), write=written.append) + + assert any("could be fooled" in line for line in written) + + +class TestChecksum: + + def test_the_binary_checksum_can_be_matched_on(self, db, config, connection): + connection.process_checksums["process.hash.md5"] = "d41d8cd98f00b204e9800998ecf8427e" + db.record_pending("unix:/local", "sig", connection, {}) + + entry = db.pending()[0] + con = review.entry_connection(entry) + candidates = [c for c in operands.candidates(con) + if c["operand"] == "process.hash.md5"] + + assert len(candidates) == 1 + assert candidates[0]["data"] == "d41d8cd98f00b204e9800998ecf8427e" + + def test_not_offered_when_the_daemon_did_not_send_one(self, db, config, connection): + con = review.entry_connection( + db.get_pending(db.record_pending("unix:/local", "s", connection, {})[0])) + assert [c for c in operands.candidates(con) + if c["operand"] == "process.hash.md5"] == [] + + class TestRendering: def test_shows_the_process_destination_and_provisional_rule(self, db, config, connection): From 2aabf3df401ae204655edb8c33df84bb50ef079a Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 20:50:17 -0700 Subject: [PATCH 12/24] cli: record when each node was last seen, and tidy up Ping arrives once a second per node and deliberately doesn't touch the database, so the last_seen column only ever held the time the node subscribed and 'opensnitch-cli nodes' showed a stale value. The housekeeping thread now writes it every 30 seconds. Config grows a set() method so the command line overrides stop reaching into its parser, and validation runs on them too. Removes an unused import, a piece of state that was never filled in, and marks the pop-up's re-exported field names with __all__ so linters stop reporting them as unused. Co-Authored-By: Claude Opus 5 --- ui/opensnitch/cli/config.py | 5 +++++ ui/opensnitch/cli/main.py | 7 +++---- ui/opensnitch/cli/server.py | 5 +++++ ui/opensnitch/cli/service.py | 13 +++++++++++-- ui/opensnitch/dialogs/prompt/constants.py | 8 ++++++++ ui/tests/cli/conftest.py | 3 --- ui/tests/cli/test_policy.py | 4 ++-- ui/tests/cli/test_service.py | 4 ++-- 8 files changed, 36 insertions(+), 13 deletions(-) diff --git a/ui/opensnitch/cli/config.py b/ui/opensnitch/cli/config.py index cf721c3d37..f63af9dab0 100644 --- a/ui/opensnitch/cli/config.py +++ b/ui/opensnitch/cli/config.py @@ -156,5 +156,10 @@ def getbool(self, section, option): raise ConfigError("{0}.{1}: '{2}' is not a boolean".format( section, option, self.get(section, option))) + def set(self, section, option, value): + """override a setting from the command line.""" + self._parser.set(section, option, str(value)) + self._validate() + def db_path(self): return os.path.expanduser(self.get("db", "path")) diff --git a/ui/opensnitch/cli/main.py b/ui/opensnitch/cli/main.py index dd17acef43..86ab0e9834 100644 --- a/ui/opensnitch/cli/main.py +++ b/ui/opensnitch/cli/main.py @@ -24,7 +24,6 @@ import argparse import json import logging -import os import sys from opensnitch.version import version @@ -56,7 +55,7 @@ def cmd_serve(args, config): from opensnitch.cli.server import Server if args.socket is not None: - config._parser.set("server", "address", args.socket) + config.set("server", "address", args.socket) server = Server(config) try: @@ -117,7 +116,7 @@ def cmd_review(args, config): def cmd_decide(args, config): """allow / deny / reject without the interactive loop.""" - from opensnitch.cli import review, rules + from opensnitch.cli import review db = open_db(config) entry = db.get_pending(args.id) @@ -296,7 +295,7 @@ def main(argv=None): return 2 if args.db is not None: - config._parser.set("db", "path", args.db) + config.set("db", "path", args.db) setup_logging(config, args.log_level) diff --git a/ui/opensnitch/cli/server.py b/ui/opensnitch/cli/server.py index b80c07548a..b9531a0585 100644 --- a/ui/opensnitch/cli/server.py +++ b/ui/opensnitch/cli/server.py @@ -41,6 +41,7 @@ logger = logging.getLogger(__name__) OUTBOX_INTERVAL = 1.0 +LAST_SEEN_INTERVAL = 30.0 PURGE_INTERVAL = 3600.0 @@ -223,9 +224,13 @@ def _outbox_loop(self): def _housekeeping_loop(self): retention = self._config.getint("db", "retention_days") last_purge = 0 + last_seen = 0 while not self._exit.is_set(): try: self._db.expire_provisionals() + if time.time() - last_seen > LAST_SEEN_INTERVAL: + self._service.persist_last_seen(self._db) + last_seen = time.time() if time.time() - last_purge > PURGE_INTERVAL: removed = self._db.purge(retention) if removed: diff --git a/ui/opensnitch/cli/service.py b/ui/opensnitch/cli/service.py index 04e8ef9c35..9448aec7a1 100644 --- a/ui/opensnitch/cli/service.py +++ b/ui/opensnitch/cli/service.py @@ -62,8 +62,6 @@ def __init__(self, db, policy, config): self._nodes = {} self._lock = threading.RLock() self._exit = threading.Event() - # notification id -> outbox row, filled in by the outbox thread - self._sent = {} # node bookkeeping @@ -86,6 +84,17 @@ def nodes(self): with self._lock: return list(self._nodes.values()) + def persist_last_seen(self, db): + """writes when each node was last heard from. + + Ping arrives once a second per node, so it only updates the value in + memory; this is called from the housekeeping thread now and then. + """ + for node in self.nodes(): + if node.stop.is_set(): + continue + db.node_seen(node.addr, node.hostname, node.version, online=True) + def shutdown(self): """asks every daemon to close its notifications stream.""" self._exit.set() diff --git a/ui/opensnitch/dialogs/prompt/constants.py b/ui/opensnitch/dialogs/prompt/constants.py index f23ca230e9..9cb294a471 100644 --- a/ui/opensnitch/dialogs/prompt/constants.py +++ b/ui/opensnitch/dialogs/prompt/constants.py @@ -10,6 +10,14 @@ APPIMAGE_PREFIX, SNAP_PREFIX ) +# re-exported, so that the rest of the pop-up keeps using constants.FIELD_* +__all__ = [ + "FIELD_REGEX_HOST", "FIELD_REGEX_IP", "FIELD_PROC_PATH", "FIELD_PROC_ARGS", + "FIELD_PROC_ID", "FIELD_USER_ID", "FIELD_DST_IP", "FIELD_DST_PORT", + "FIELD_DST_NETWORK", "FIELD_DST_HOST", "FIELD_APPIMAGE", "FIELD_SNAP", + "APPIMAGE_PREFIX", "SNAP_PREFIX", +] + PAGE_MAIN = 2 PAGE_DETAILS = 0 PAGE_CHECKSUMS = 1 diff --git a/ui/tests/cli/conftest.py b/ui/tests/cli/conftest.py index 227c5a60a1..9dc8837afc 100644 --- a/ui/tests/cli/conftest.py +++ b/ui/tests/cli/conftest.py @@ -5,9 +5,6 @@ # here by ones that do nothing: pytest resolves fixtures from the closest # conftest, so the GUI tests keep using the originals. -import os -import tempfile - import pytest from opensnitch.cli.config import Config diff --git a/ui/tests/cli/test_policy.py b/ui/tests/cli/test_policy.py index 9f0130b498..3984ec9389 100644 --- a/ui/tests/cli/test_policy.py +++ b/ui/tests/cli/test_policy.py @@ -97,7 +97,7 @@ def test_unreviewed_connections_are_denied_by_default(self, db, config, connecti assert p.on_ask("unix:/local", connection).action == "deny" def test_can_be_made_fail_open(self, db, config, connection): - config._parser.set("policy", "unreviewed_action", "allow") + config.set("policy", "unreviewed_action", "allow") p = policy.Policy(db, config) rule = p.on_ask("unix:/local", connection) @@ -122,7 +122,7 @@ def test_records_the_provisional_rule(self, db, config, connection): assert entry["provisional_expires"] is not None def test_full_queue_still_answers(self, db, config, connection): - config._parser.set("policy", "queue_max", "1") + config.set("policy", "queue_max", "1") p = policy.Policy(db, config) p.on_ask("unix:/local", connection) diff --git a/ui/tests/cli/test_service.py b/ui/tests/cli/test_service.py index 85727e9bd1..f15bfeb1de 100644 --- a/ui/tests/cli/test_service.py +++ b/ui/tests/cli/test_service.py @@ -83,7 +83,7 @@ def test_overrides_the_default_action(self, db, config): assert json.loads(reply.config)["DefaultAction"] == "deny" def test_the_default_action_follows_the_configuration(self, db, config): - config._parser.set("policy", "default_action", "allow") + config.set("policy", "default_action", "allow") service = make_service(db, config) reply = service.Subscribe(client_config(default_action="deny"), FakeContext()) @@ -129,7 +129,7 @@ def test_answers_quickly(self, db, config, connection): assert time.time() - start < 1.0 def test_allow_policy(self, db, config, connection): - config._parser.set("policy", "unreviewed_action", "allow") + config.set("policy", "unreviewed_action", "allow") service = make_service(db, config) assert service.AskRule(connection, FakeContext()).action == "allow" From e8d261cdeaaa40e8c95b39fe6ff73a08c557349f Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 20:50:17 -0700 Subject: [PATCH 13/24] cli: document installing, using and checking opensnitch-cli Covers what it is for and why it cannot simply ask, installing on a machine without Qt, pointing the daemon at it, the systemd unit, a step by step check that it works against a real daemon, the review keys and the rule editor, every subcommand, the configuration, TLS, and what to do when something is wrong. Co-Authored-By: Claude Opus 5 --- ui/opensnitch/cli/README.md | 376 ++++++++++++++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 ui/opensnitch/cli/README.md diff --git a/ui/opensnitch/cli/README.md b/ui/opensnitch/cli/README.md new file mode 100644 index 0000000000..23cfe8de40 --- /dev/null +++ b/ui/opensnitch/cli/README.md @@ -0,0 +1,376 @@ +# opensnitch-cli + +A client for machines with no graphical environment. + +`opensnitch-ui` is the usual way to answer "should this program be allowed to +connect?", but it needs Qt and someone in front of the screen. On a server there +is nobody to click, and the daemon only intercepts while a client is connected — +with none attached it falls back to `clientDisconnectedRule.Action`, so an +unattended server ends up allowing everything. + +`opensnitch-cli` fills that gap. It is an addition, not a replacement: nothing +about `opensnitch-ui` changes. + +* `opensnitch-cli serve` runs as a service, answers the daemon, and records every + connection it hasn't seen before in a review queue. +* `opensnitch-cli review` goes through that queue afterwards, one connection at a + time, and turns entries into permanent rules. + +**Only one client can own a daemon's socket.** Run `opensnitch-ui` *or* +`opensnitch-cli serve`, not both. + +## Why it can't simply ask you + +The daemon holds the packet in a netfilter queue while it waits for an answer, +and gives up after 120 seconds. So `serve` cannot wait for a person: it answers +straight away with a short temporary rule and queues the connection. The decision +you take later is what governs every connection after that. + +By default an unreviewed connection is **denied** for an hour and queued. The +temporary rule is short lived on purpose, so a connection nobody ever reviews +comes back to the queue instead of being decided for good. + +If you would rather a server keep working while the queue waits for you, set +`policy.unreviewed_action = allow`. It is still recorded either way. + +## Requirements + +Python 3, and three modules that the graphical interface already depends on: + +``` +python3-grpcio python3-protobuf python3-slugify python3-packaging +``` + +No PyQt. On Debian and Ubuntu: + +```bash +sudo apt install python3-grpcio python3-protobuf python3-slugify python3-packaging +``` + +## Installing + +Until the packaging is split (see "Packaging" below), the deb and rpm both pull +in PyQt6 for the graphical interface, so on a server install from source. The +code itself needs no Qt. + +```bash +git clone https://github.com/evilsocket/opensnitch.git /opt/opensnitch +cd /opt/opensnitch/ui +sudo pip3 install . +``` + +On distributions that refuse a system wide `pip install` (Debian 12 and later, +Ubuntu 24.04 and later) either add `--break-system-packages`, or skip installing +altogether and run it out of the source tree: + +```bash +sudo PYTHONPATH=/opt/opensnitch/ui /opt/opensnitch/ui/bin/opensnitch-cli --help +``` + +`setup.py` declares no dependencies, so installing it will not drag PyQt6 in. + +## Pointing the daemon at it + +The daemon and the client have to agree on a socket. The default on both sides is +`unix:///tmp/osui.sock`, so if you are keeping that, there is nothing to change. + +To use a socket that is not world traversable, set it in +`/etc/opensnitchd/default-config.json`: + +```json +{ + "Server": { + "Address": "unix:///run/opensnitch/cli.sock" + } +} +``` + +and the same value in `/etc/opensnitch/cli.conf`: + +```ini +[server] +address = unix:///run/opensnitch/cli.sock +``` + +Then restart the daemon: `sudo systemctl restart opensnitchd`. + +## Running it as a service + +```bash +sudo cp /opt/opensnitch/ui/resources/init/opensnitch-cli.service /etc/systemd/system/ +sudo cp /opt/opensnitch/ui/resources/cli.conf.example /etc/opensnitch/cli.conf +sudo systemctl daemon-reload +sudo systemctl enable --now opensnitch-cli +``` + +Make sure the graphical interface is not running first: + +```bash +sudo systemctl stop opensnitch-ui 2>/dev/null; pkill -f opensnitch-ui +``` + +The unit keeps its database in `/var/lib/opensnitch` (`StateDirectory=`, +mode 0700). It sets `PrivateTmp=no` because the default socket lives in `/tmp`; +if you moved the socket to `/run/opensnitch` as above, uncomment +`RuntimeDirectory=opensnitch` and set `PrivateTmp=yes`. + +## Checking that it works + +Run `serve` in the foreground the first time, so you can see what it does. + +**1. Start it and confirm the daemon connects.** + +```bash +sudo opensnitch-cli serve --log-level debug +``` + +You should see it listening, and then the daemon's log +(`journalctl -fu opensnitchd`) should show `Connected to the UI service` and +`Start receiving notifications`. Confirm from another terminal: + +```bash +sudo opensnitch-cli nodes +``` + +**2. Make a connection that has no rule yet.** + +```bash +curl -sS https://example.com +``` + +With the default policy this should **fail**, and `serve` should log the +connection and the rule it answered with. + +**3. It should be in the queue.** + +```bash +sudo opensnitch-cli pending +``` + +``` +ID PROCESS DESTINATION SEEN +1 /usr/bin/curl example.com:443 1x +``` + +Running `curl` again does **not** increase `SEEN`: the temporary rule now matches, +so the daemon stops asking. That is expected. To watch the counter move, set +`unreviewed_duration = 30s` and try again after it expires. + +**4. Approve it.** + +```bash +sudo opensnitch-cli review +``` + +``` +────────────────────────────────────────────────────────────── +[1/1] unix:/local seen 1x first 2026-08-13 20:17:05 last 2026-08-13 20:17:05 + /usr/bin/curl pid 41233 uid 1000 + -> tcp example.com (93.184.216.34) port 443 + currently deny for 1h, 59m12s left (temporary rule cli-auto-9f2a1b3c4d5e) + + proposed rule: allow-always-simple-usr-bin-curl + allow always + process.path is /usr/bin/curl + + [y]es [n]o [r]eject [e]dit [s]kip [d]rop [i]nfo [q]uit ? +``` + +Press `y`. It should say the rule was queued, and within a second `serve` should +log a `DELETE_RULE` for the temporary rule followed by a `CHANGE_RULE` for the +new one. + +**5. Confirm the rule reached the daemon.** + +```bash +ls /etc/opensnitchd/rules/ | grep allow-always +sudo opensnitch-cli status # notifications failed should be 0 +curl -sS https://example.com # should now succeed +``` + +Only `always` rules are written to disk by the daemon; everything shorter lives +in its memory. + +**6. Check the deny path.** + +```bash +curl -sS https://example.org +sudo opensnitch-cli deny $(sudo opensnitch-cli pending --json | python3 -c 'import json,sys;print(json.load(sys.stdin)[0]["id"])') +curl -sS https://example.org # should still fail +``` + +**7. Check that a decision survives the service being stopped.** + +```bash +sudo systemctl stop opensnitch-cli +sudo opensnitch-cli allow # queued, not sent +sudo opensnitch-cli status # notifications queued: 1 +sudo systemctl start opensnitch-cli +sudo opensnitch-cli status # back to 0, the rule was sent on start up +``` + +## Editing a rule before applying it + +`e` in the review loop opens the editor. It offers the same choices the graphical +pop-up does, because both are built from the same code +(`ui/tests/cli/test_operands_parity.py` checks they cannot drift apart): + +``` + 1) match on process.path is /usr/bin/curl + 2) action allow + 3) duration always + 4) name allow-always-simple-usr-bin-curl + 5) also require (nothing) + 6) precedence no + a) apply c) cancel +``` + +`1` lists everything the connection can be matched on: + +``` + 1) /usr/bin/curl simple process.path + 2) /usr/bin/curl -sSL https://api.github.com/repos simple process.command + 3) api.github.com simple dest.host + 4) ^(|.*\.)github\.com$ regexp dest.host + 5) 140.82.121.6 simple dest.ip + 6) 140\.82\..* regexp dest.ip + 7) 140.82.121.0/24 network dest.network + 8) 443 simple dest.port + 9) 1000 simple user.id + c) something else, typed by hand +``` + +So you can approve the executable, the exact command line, one host, a whole +domain and its subdomains, an address range, a network, a port, a user, or a +pattern of your own. `c` asks for the type, operand and value directly. + +`5` adds further conditions, which produces a `list` rule — the same thing the +pop-up's "also match" checkboxes build. + +**The proposed rule matches the executable, not the destination.** That is the +pop-up's default too. So `y` allows the program to reach anywhere, and `n` +blocks it everywhere — not just the destination you were shown. If you only mean +this destination, use `e` and pick the host or address, or +`--match dest.host` on the command line. The rule is always printed before it is +queued, so check the operand line before answering. + +Matching on the command line is treated carefully: a program picks its own +`argv[0]`, so when it is not an absolute path (or lives under `/proc`) the +executable is pinned as well, and you are told why. The pop-up does the same +thing. + +Patterns are checked before they are sent. The daemon compiles regular +expressions with Go's RE2, which has no lookaround and no backreferences, so +those are refused up front instead of being silently dropped by the daemon. A +pattern with upper case letters on a non case sensitive operand is flagged too, +because the daemon lowercases it and it would never match. + +## Commands + +| Command | What it does | +| --- | --- | +| `serve` | answer the daemon and record connections | +| `review` | go through the queue one at a time | +| `pending [--json] [--node N] [--limit N]` | list what is waiting | +| `allow ID [--match OPERAND] [--duration D] [--name N]` | approve without prompting | +| `deny ID` / `reject ID` | refuse without prompting | +| `drop ID` | remove from the queue without creating a rule | +| `rules [--node N] [--json]` | rules the daemon reported when it connected | +| `nodes [--json]` | daemons that have connected | +| `status [--json]` | queue depth, nodes, and rules the daemon rejected | + +`status` exits non-zero if any rule was rejected, so it works as a monitoring +check. + +`--match` takes an operand name, for example: + +```bash +sudo opensnitch-cli allow 3 --match dest.host --duration always +``` + +## Configuration + +`/etc/opensnitch/cli.conf`, or `~/.config/opensnitch/cli.conf`. Running with no +configuration file at all works; the defaults are the whole contract. See +`resources/cli.conf.example` for the annotated version. The settings that matter +most: + +| Setting | Default | Meaning | +| --- | --- | --- | +| `server.address` | `unix:///tmp/osui.sock` | must match the daemon's `Server.Address` | +| `server.auth_type` | `simple` | `simple`, `tls-simple` or `tls-mutual`, same as the GUI | +| `policy.unreviewed_action` | `deny` | `allow` makes it fail open | +| `policy.unreviewed_duration` | `1h` | how long that answer lasts before it is asked about again | +| `policy.default_action` | `deny` | what the daemon does while it is already asking about another connection | +| `policy.queue_max` | `1000` | stop recording past this many; connections are still answered | +| `db.path` | `/var/lib/opensnitch/cli.db` | the queue | + +Durations are what Go's `time.ParseDuration` accepts — `30s`, `5m`, `1h30m` — +plus `until restart` and `always`. **Days and weeks are not supported by the +daemon.** `once` is refused: a rule with that duration sent over the +notifications channel is never removed and would live until the daemon restarts. + +## TLS + +Exactly the same options and certificates the graphical interface uses: + +```ini +[server] +auth_type = tls-simple +tls_ca_cert = /etc/opensnitch/ca.crt +tls_cert = /etc/opensnitch/server.crt +tls_key = /etc/opensnitch/server.key +``` + +## When something is wrong + +**The daemon does not connect.** Check both ends agree on the address, and that +the socket exists: `ls -l /tmp/osui.sock` should be `srw-r-----`. If you are +running under systemd with the default socket, `PrivateTmp` must be `no` or the +daemon is looking at a different `/tmp`. + +**`could not listen on ...`** — something already owns that socket, almost always +`opensnitch-ui`. Only one client per daemon. + +**`Permission denied` opening the database.** The queue decides what the machine +may connect to, so it is root-only. Use `sudo`. + +**A rule never took effect.** `sudo opensnitch-cli status` lists rules the daemon +refused, with its own error message. The usual causes are a regular expression +RE2 cannot compile and a duration Go cannot parse. + +**Everything is blocked and you need to get out of it.** Set +`policy.unreviewed_action = allow` and restart, or stop `opensnitch-cli` +entirely: with no client connected the daemon applies its own `DefaultAction` +from `/etc/opensnitchd/default-config.json`. + +## Differences from the graphical interface + +* No live connection or statistics browsing. The daemon's `Ping` statistics are + not stored; the review queue is fed by the connections the daemon actually asks + about. +* No firewall (nftables) configuration. +* Multiple nodes are recorded and can be filtered with `--node`, but there is no + per-node management beyond that. +* It never blocks waiting for a person, by design. + +## Packaging + +The client currently ships inside `python3-opensnitch-ui`, which depends on +PyQt6. The code has no Qt dependency — `ui/tests/cli/test_no_qt.py` imports the +whole package in a fresh interpreter and fails if anything reaches Qt — so +installing from source on a server needs no Qt at all. + +A Qt-free binary package needs the source split into three: a +`python3-opensnitch-common` holding `opensnitch/{version,rule_consts,operands, +auth,proto}` (about 400 hand written lines plus the generated protobuffers), with +`python3-opensnitch-ui` and `opensnitch-cli` both depending on it. That +restructures how the existing package is built, so it is left for its own change. + +## Tests + +```bash +cd ui/tests && pytest -v cli/ +``` + +They need neither Qt nor a display. From ec43f98ed843694300b50908e7048efca28a798a Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 20:58:04 -0700 Subject: [PATCH 14/24] cli: stop describing the package split as unfinished work setup.py declares no dependencies, so installing from source or with pip pulls in nothing and a server gets no Qt. That is the server install, and it means this does not need to touch the deb or rpm at all. Giving the client a package of its own would only serve people who want to apt install it rather than use pip, and it would mean splitting the shared modules into a third binary package. Says so plainly instead of presenting it as something left half done. Co-Authored-By: Claude Opus 5 --- ui/opensnitch/cli/README.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/ui/opensnitch/cli/README.md b/ui/opensnitch/cli/README.md index 23cfe8de40..095d379fde 100644 --- a/ui/opensnitch/cli/README.md +++ b/ui/opensnitch/cli/README.md @@ -356,16 +356,15 @@ from `/etc/opensnitchd/default-config.json`. ## Packaging -The client currently ships inside `python3-opensnitch-ui`, which depends on -PyQt6. The code has no Qt dependency — `ui/tests/cli/test_no_qt.py` imports the -whole package in a fresh interpreter and fails if anything reaches Qt — so -installing from source on a server needs no Qt at all. - -A Qt-free binary package needs the source split into three: a -`python3-opensnitch-common` holding `opensnitch/{version,rule_consts,operands, -auth,proto}` (about 400 hand written lines plus the generated protobuffers), with -`python3-opensnitch-ui` and `opensnitch-cli` both depending on it. That -restructures how the existing package is built, so it is left for its own change. +`setup.py` declares no dependencies, so installing from source or with `pip` +pulls in nothing: a server gets the client and the three modules listed under +Requirements, and no Qt. `ui/tests/cli/test_no_qt.py` imports the whole package +in a fresh interpreter and fails if anything reaches Qt. + +The deb and rpm build one binary package, `python3-opensnitch-ui`, which depends +on PyQt6 for the graphical interface, so the client ships inside it. Giving it a +package of its own would mean splitting the shared modules out into a third one — +worth doing only if there is demand for `apt install opensnitch-cli` on servers. ## Tests From 970cf85a325ec0776c58d437bc711896681eb363 Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 21:05:22 -0700 Subject: [PATCH 15/24] cli: refuse to start when something else already serves the socket grpc does not fail when a unix socket is already there. It unlinks it, puts its own in its place, and add_insecure_port still reports success, so the check for a zero port never fired for the case it was written for: starting while opensnitch-ui is running quietly took the daemon away from it, and the graphical interface carried on looking connected. Connect to the socket first and refuse if anyone answers. A socket left behind by a process that died is still allowed through, so a crash doesn't wedge the service. Found by the new end to end test, which runs the real server on a real unix socket and drives it with a real client the way opensnitchd does: subscribe, notifications stream, ping, ask, and answer. The other tests use a fake context and cannot see any of this. It is also the quickest way to check the client works on a machine that has no daemon installed. Co-Authored-By: Claude Opus 5 --- ui/opensnitch/cli/server.py | 32 +++++ ui/tests/cli/test_endtoend.py | 214 ++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 ui/tests/cli/test_endtoend.py diff --git a/ui/opensnitch/cli/server.py b/ui/opensnitch/cli/server.py index b9531a0585..24f391d688 100644 --- a/ui/opensnitch/cli/server.py +++ b/ui/opensnitch/cli/server.py @@ -24,6 +24,7 @@ import logging import os import signal +import socket import threading import time from concurrent import futures @@ -60,6 +61,36 @@ def unix_socket_path(address): return None +def check_socket_free(sock_path): + """refuses to start if something is already serving that socket. + + grpc does not fail when a unix socket is already there: it unlinks it and + puts its own in its place, and add_insecure_port still reports success. So + starting while opensnitch-ui is running would quietly take the daemon away + from it. Ask the socket whether anyone is home instead. + """ + if not os.path.exists(sock_path): + return + + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + probe.settimeout(1) + try: + probe.connect(sock_path) + except (ConnectionRefusedError, FileNotFoundError): + # nothing behind it, it's left over from a process that died + logger.info("removing the socket left behind at %s", sock_path) + return + except OSError: + # can't tell, let grpc have a go + return + else: + raise RuntimeError( + "{0} is already being served. opensnitch-ui or another opensnitch-cli " + "is using it, and a daemon can only talk to one of them".format(sock_path)) + finally: + probe.close() + + class Server: def __init__(self, config, database=None): self._config = config @@ -101,6 +132,7 @@ def start(self): directory = os.path.dirname(sock_path) if directory != "" and not os.path.isdir(directory): os.makedirs(directory, mode=0o700, exist_ok=True) + check_socket_free(sock_path) # a worker is taken for as long as a node's notifications stream is # open, one more while a connection is being asked about, and Ping needs diff --git a/ui/tests/cli/test_endtoend.py b/ui/tests/cli/test_endtoend.py new file mode 100644 index 0000000000..fde16ef6ca --- /dev/null +++ b/ui/tests/cli/test_endtoend.py @@ -0,0 +1,214 @@ +# +# pytest -v cli/test_endtoend.py +# +# The other tests drive the service directly with a fake context. This one runs +# the real gRPC server on a real unix socket and talks to it with a real client, +# doing what opensnitchd does: subscribe, open the notifications stream, ping, +# ask about a connection, and answer the notifications it is sent. +# +# It is the closest thing to a live daemon that doesn't need root or netfilter, +# so it's also the quickest way to check the client works on a new machine. + +import json +import os +import queue +import threading +import time + +import grpc +import pytest +from google.protobuf import json_format + +from opensnitch.cli import db as dbmod, rules +from opensnitch.cli.config import Config +from opensnitch.cli.proto import ui_pb2, ui_pb2_grpc +from opensnitch.cli.server import Server + +TIMEOUT = 10 + + +class FakeDaemon: + """acts like opensnitchd: dials the client and answers its notifications.""" + + def __init__(self, address): + self.channel = grpc.insecure_channel(address) + self.stub = ui_pb2_grpc.UIStub(self.channel) + self.outgoing = queue.Queue() + self.received = [] + self._stream = None + self._reader = None + + def subscribe(self, default_action="deny"): + config = json.dumps({"DefaultAction": default_action, "InterceptUnknown": False}) + return self.stub.Subscribe( + ui_pb2.ClientConfig(id=1, name="testnode", version="6.0", config=config), + timeout=TIMEOUT) + + def open_notifications(self): + # the daemon says hello before anything is sent to it + self.outgoing.put(ui_pb2.NotificationReply(id=0, code=ui_pb2.OK)) + + def replies(): + while True: + item = self.outgoing.get() + if item is None: + return + yield item + + self._stream = self.stub.Notifications(replies()) + + def read(): + try: + for notification in self._stream: + self.received.append(notification) + # answer it the way the daemon does + self.outgoing.put(ui_pb2.NotificationReply( + id=notification.id, code=ui_pb2.OK)) + except grpc.RpcError: + pass + + self._reader = threading.Thread(target=read, daemon=True) + self._reader.start() + + def wait_for(self, count): + deadline = time.time() + TIMEOUT + while len(self.received) < count and time.time() < deadline: + time.sleep(0.05) + return list(self.received) + + def close(self): + self.outgoing.put(None) + self.channel.close() + + +@pytest.fixture +def running(tmp_path): + """a client listening on a unix socket, and a daemon connected to it.""" + socket = "unix://%s" % (tmp_path / "osui.sock") + conf = tmp_path / "cli.conf" + conf.write_text( + "[server]\naddress = %s\n" + "[policy]\nunreviewed_action = deny\nunreviewed_duration = 30s\n" + "[db]\npath = %s\n" % (socket, tmp_path / "cli.db")) + + server = Server(Config(path=str(conf))) + server.start() + daemon = FakeDaemon(socket) + try: + yield server, daemon + finally: + daemon.close() + server.stop(grace=1) + + +def a_connection(host="api.github.com"): + return ui_pb2.Connection( + protocol="tcp", dst_ip="140.82.121.6", dst_host=host, dst_port=443, + user_id=1000, process_id=41233, process_path="/usr/bin/curl", + process_args=["/usr/bin/curl", "-sSL", "https://%s" % host]) + + +class TestOverARealSocket: + + def test_the_socket_is_not_world_readable(self, running, tmp_path): + """it decides what the machine may connect to; other users can't have it.""" + mode = os.stat(str(tmp_path / "osui.sock")).st_mode & 0o777 + assert mode == 0o640 + + def test_subscribe_answers_with_our_default_action(self, running): + server, daemon = running + reply = daemon.subscribe(default_action="allow") + + assert json.loads(reply.config)["DefaultAction"] == "deny" + assert len(server.db.nodes()) == 1 + + def test_ping_echoes_the_id(self, running): + server, daemon = running + daemon.subscribe() + assert daemon.stub.Ping(ui_pb2.PingRequest(id=987654), timeout=TIMEOUT).id == 987654 + + def test_a_connection_is_answered_and_queued(self, running): + server, daemon = running + daemon.subscribe() + + started = time.time() + rule = daemon.stub.AskRule(a_connection(), timeout=TIMEOUT) + elapsed = time.time() - started + + # the daemon is holding the packet and gives up after 120s + assert elapsed < 5 + assert rule.action == "deny" + assert rule.duration == "30s" + assert server.db.pending_count() == 1 + + def test_asking_again_counts_instead_of_duplicating(self, running): + server, daemon = running + daemon.subscribe() + daemon.stub.AskRule(a_connection(), timeout=TIMEOUT) + daemon.stub.AskRule(a_connection(), timeout=TIMEOUT) + + assert server.db.pending_count() == 1 + assert server.db.pending()[0]["hits"] == 2 + + def test_different_destinations_are_separate_entries(self, running): + server, daemon = running + daemon.subscribe() + daemon.stub.AskRule(a_connection("api.github.com"), timeout=TIMEOUT) + daemon.stub.AskRule(a_connection("pypi.org"), timeout=TIMEOUT) + + assert server.db.pending_count() == 2 + + def test_a_decision_reaches_the_daemon_in_order(self, running): + """the temporary rule is withdrawn first, then the real one installed.""" + server, daemon = running + daemon.subscribe() + daemon.open_notifications() + daemon.stub.AskRule(a_connection(), timeout=TIMEOUT) + + entry = server.db.pending()[0] + final = rules.build_rule( + "allow-always-simple-github", "allow", "always", + [rules.new_operator("regexp", "dest.host", r"^(|.*\.)github\.com$")]) + + stale = ui_pb2.Rule(name=entry["provisional_name"]) + stale.operator.type = "simple" + stale.operator.operand = "true" + server.db.queue_notification(entry["node"], ui_pb2.DELETE_RULE, + json_format.MessageToJson(stale)) + server.db.queue_notification(entry["node"], ui_pb2.CHANGE_RULE, + json_format.MessageToJson(final)) + server.drain_outbox() + + got = daemon.wait_for(2) + assert [n.type for n in got] == [ui_pb2.DELETE_RULE, ui_pb2.CHANGE_RULE] + assert got[0].rules[0].name == entry["provisional_name"] + assert got[1].rules[0].name == "allow-always-simple-github" + assert got[1].rules[0].operator.data == r"^(|.*\.)github\.com$" + + def test_the_daemons_answer_is_recorded(self, running): + server, daemon = running + daemon.subscribe() + daemon.open_notifications() + + outbox_id = server.db.queue_notification( + "unix:/local", ui_pb2.CHANGE_RULE, + json_format.MessageToJson(ui_pb2.Rule(name="r"))) + server.drain_outbox() + daemon.wait_for(1) + + deadline = time.time() + TIMEOUT + while server.db.get_outbox(outbox_id)["state"] != dbmod.OUT_DONE: + if time.time() > deadline: + break + time.sleep(0.05) + assert server.db.get_outbox(outbox_id)["state"] == dbmod.OUT_DONE + + def test_a_second_client_cannot_take_the_socket(self, running, tmp_path): + """opensnitch-ui and opensnitch-cli serve cannot both own one daemon.""" + server, _ = running + conf = tmp_path / "second.conf" + conf.write_text("[server]\naddress = unix://%s\n[db]\npath = %s\n" + % (tmp_path / "osui.sock", tmp_path / "second.db")) + + with pytest.raises(RuntimeError, match="already being served"): + Server(Config(path=str(conf))).start() From 15123fd02357a83a53999e84dc64ed2d96797f14 Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 21:14:32 -0700 Subject: [PATCH 16/24] cli: survive the daemon restarting A daemon reconnecting over a unix socket arrives with the same peer string as before, so Subscribe reused the previous session's node, whose stop event had fired when that session's stream closed. The new notifications stream then ended the moment it opened, the daemon treated that as a disconnect and tried again, and the loop repeated until the service was restarted. Worse than it sounds: between attempts the daemon applies its disconnected default, which is allow, so a machine configured to deny quietly stopped denying after the first daemon restart. Subscribe now starts a fresh session every time; the old one, if it is somehow still open, ends through its own stop event, and only the current session marks the node offline. A notification the daemon never answered also stayed marked as sent until the service restarted, so a decision taken just as the daemon went away was lost while the service kept running. When a node's stream closes, its unanswered notifications now go back in the queue and are sent again on reconnect, which is safe: the daemon replaces rules by name and deleting an absent rule does nothing. Also: don't require the same operand twice when the match is changed to a candidate that was already added as a condition, and the queue-full counter is only in the log, not in status, so say so. Co-Authored-By: Claude Opus 5 --- ui/opensnitch/cli/db.py | 17 +++++---- ui/opensnitch/cli/policy.py | 3 +- ui/opensnitch/cli/review.py | 4 +++ ui/opensnitch/cli/server.py | 5 +-- ui/opensnitch/cli/service.py | 38 ++++++++++++++++---- ui/tests/cli/test_db.py | 11 ++++++ ui/tests/cli/test_endtoend.py | 66 +++++++++++++++++++++++++++++++++++ ui/tests/cli/test_review.py | 19 ++++++++++ 8 files changed, 148 insertions(+), 15 deletions(-) diff --git a/ui/opensnitch/cli/db.py b/ui/opensnitch/cli/db.py index 7873005b02..6d3f3e5bd3 100644 --- a/ui/opensnitch/cli/db.py +++ b/ui/opensnitch/cli/db.py @@ -311,16 +311,21 @@ def mark_result(self, ntf_id, ok, error=None): "UPDATE outbox SET state=?, last_error=?, updated=? WHERE ntf_id=? AND state=?", (OUT_DONE if ok else OUT_ERROR, error, int(time.time()), ntf_id, OUT_SENT)) - def requeue_sent(self): + def requeue_sent(self, node=None): """puts unanswered notifications back in the queue. - Called at start up: anything still marked as sent was in flight when the - service stopped. Re-sending is safe, the daemon replaces rules by name - and deleting a rule that isn't there does nothing. + Called at start up for everything (anything still marked as sent was in + flight when the service stopped), and for one node when its stream + closes before it answered. Re-sending is safe, the daemon replaces + rules by name and deleting a rule that isn't there does nothing. """ + query = "UPDATE outbox SET state=? WHERE state=?" + args = [OUT_QUEUED, OUT_SENT] + if node is not None: + query += " AND node=?" + args.append(node) with self._lock: - cur = self._db.execute("UPDATE outbox SET state=? WHERE state=?", - (OUT_QUEUED, OUT_SENT)) + cur = self._db.execute(query, args) return cur.rowcount def get_outbox(self, outbox_id): diff --git a/ui/opensnitch/cli/policy.py b/ui/opensnitch/cli/policy.py index 17a4b6dde5..2aafc24654 100644 --- a/ui/opensnitch/cli/policy.py +++ b/ui/opensnitch/cli/policy.py @@ -153,7 +153,8 @@ def on_ask(self, node, con): # A full queue must never stop us answering: the packet is waiting. # Existing entries still count new attempts, only new signatures are - # refused, and the count is reported by 'opensnitch-cli status'. + # refused. The count only exists in this process, so it is reported + # through the log, not by 'opensnitch-cli status'. if self._db.pending_count() >= self._queue_max: existing = self._db.get_pending_by_signature(node, sig) if existing is None: diff --git a/ui/opensnitch/cli/review.py b/ui/opensnitch/cli/review.py index d68f28a4ae..ce1d90f85a 100644 --- a/ui/opensnitch/cli/review.py +++ b/ui/opensnitch/cli/review.py @@ -120,6 +120,10 @@ def operators(self): ops.append(rules.new_operator(self.selected["type"], self.selected["operand"], self.selected["data"])) for cand in self.extra: + # the match can be changed to a candidate that was already added as + # a condition; don't require the same thing twice + if cand is self.selected: + continue ops.append(rules.new_operator(cand["type"], cand["operand"], cand["data"])) if self.untrusted_command() and self.con.process_path != "": diff --git a/ui/opensnitch/cli/server.py b/ui/opensnitch/cli/server.py index 24f391d688..2125023b60 100644 --- a/ui/opensnitch/cli/server.py +++ b/ui/opensnitch/cli/server.py @@ -77,8 +77,9 @@ def check_socket_free(sock_path): try: probe.connect(sock_path) except (ConnectionRefusedError, FileNotFoundError): - # nothing behind it, it's left over from a process that died - logger.info("removing the socket left behind at %s", sock_path) + # nothing behind it, it's left over from a process that died; grpc + # replaces it when we bind + logger.info("taking over the socket left behind at %s", sock_path) return except OSError: # can't tell, let grpc have a go diff --git a/ui/opensnitch/cli/service.py b/ui/opensnitch/cli/service.py index 9448aec7a1..9b83131921 100644 --- a/ui/opensnitch/cli/service.py +++ b/ui/opensnitch/cli/service.py @@ -127,13 +127,20 @@ def Subscribe(self, node_config, context): peer = context.peer() addr = self.peer_addr(peer) + # Always a fresh Node. A daemon reconnecting over a unix socket shows up + # with the same peer string as before, and the old Node's stop event was + # set when its stream closed; reusing it would end the new notifications + # stream immediately, and the daemon would reconnect in a loop from then + # on. The old session, if one is somehow still open, ends through its own + # Node's stop event. with self._lock: - node = self._nodes.get(addr) - if node is None or node.peer != peer: - node = Node(addr, peer) - self._nodes[addr] = node + old = self._nodes.get(addr) + node = Node(addr, peer) node.hostname = node_config.name node.version = node_config.version + self._nodes[addr] = node + if old is not None: + old.stop.set() self._db.node_seen(addr, node_config.name, node_config.version, online=True) self._db.replace_rules(addr, node_config.rules) @@ -188,9 +195,8 @@ def Notifications(self, node_iter, context): return def on_closed(): - logger.info("node disconnected: %s", addr) node.stop.set() - self._db.node_offline(addr) + self._on_stream_closed(node) context.add_callback(on_closed) @@ -224,6 +230,26 @@ def _read_replies(self, node, node_iter): logger.debug("notifications stream of %s closed: %s", node.addr, repr(e)) finally: node.stop.set() + self._on_stream_closed(node) + + def _on_stream_closed(self, node): + """a notification stream ended, cleanly or not. + + Anything sent on it and not yet answered goes back in the queue, to be + sent again when the daemon comes back: re-sending is safe, the daemon + replaces rules by name and deleting a rule that isn't there does nothing. + The node is only marked offline if a newer session hasn't replaced it. + """ + requeued = self._db.requeue_sent(node.addr) + if requeued: + logger.info("%s went away with %d unanswered notifications, " + "they will be sent again when it returns", node.addr, requeued) + + with self._lock: + current = self._nodes.get(node.addr) is node + if current: + logger.info("node disconnected: %s", node.addr) + self._db.node_offline(node.addr) def PostAlert(self, alert, context): addr = self.peer_addr(context.peer()) diff --git a/ui/tests/cli/test_db.py b/ui/tests/cli/test_db.py index d9161813a0..3d14e8be40 100644 --- a/ui/tests/cli/test_db.py +++ b/ui/tests/cli/test_db.py @@ -77,6 +77,17 @@ def test_unanswered_notifications_are_sent_again_on_restart(self, db): assert db.requeue_sent() == 1 assert db.get_outbox(outbox_id)["state"] == dbmod.OUT_QUEUED + def test_requeueing_one_node_leaves_the_others_alone(self, db): + """when a node's stream drops, only its own notifications go back.""" + mine = db.queue_notification("a", 10, "{}") + other = db.queue_notification("b", 10, "{}") + db.mark_sent(mine, 1) + db.mark_sent(other, 2) + + assert db.requeue_sent("a") == 1 + assert db.get_outbox(mine)["state"] == dbmod.OUT_QUEUED + assert db.get_outbox(other)["state"] == dbmod.OUT_SENT + def test_a_decision_survives_the_service_being_down(self, db): """review writes, serve reads later: nothing is lost in between.""" db.queue_notification("n", 10, '{"name":"r"}') diff --git a/ui/tests/cli/test_endtoend.py b/ui/tests/cli/test_endtoend.py index fde16ef6ca..d85c69c0fa 100644 --- a/ui/tests/cli/test_endtoend.py +++ b/ui/tests/cli/test_endtoend.py @@ -203,6 +203,72 @@ def test_the_daemons_answer_is_recorded(self, running): time.sleep(0.05) assert server.db.get_outbox(outbox_id)["state"] == dbmod.OUT_DONE + def test_a_restarted_daemon_can_come_back(self, running, tmp_path): + """the daemon reconnects with the same peer string over a unix socket. + + The first version of the service kept the old session's state and its + already-fired stop event, so after one daemon restart the notifications + stream ended the moment it opened and no decision could ever be + delivered again. Three lifetimes over the same socket, to be sure. + """ + server, daemon = running + daemon.subscribe() + daemon.open_notifications() + daemon.close() + time.sleep(0.3) + + second = FakeDaemon("unix://%s" % (tmp_path / "osui.sock")) + try: + second.subscribe() + second.open_notifications() + + server.db.queue_notification("unix:/local", ui_pb2.CHANGE_RULE, + json_format.MessageToJson(ui_pb2.Rule(name="after"))) + server.drain_outbox() + + got = second.wait_for(1) + assert len(got) == 1, "the reconnected daemon never got the notification" + assert got[0].rules[0].name == "after" + finally: + second.close() + + def test_a_decision_the_daemon_never_answered_is_sent_again(self, running, tmp_path): + """the stream can drop between sending and the daemon's reply. + + The row was stuck in 'sent' until the service restarted; now it goes + back in the queue when the stream closes, and the next session gets it. + """ + server, daemon = running + daemon.subscribe() + # no notifications stream answering: send, then let the daemon vanish + outbox_id = server.db.queue_notification( + "unix:/local", ui_pb2.CHANGE_RULE, + json_format.MessageToJson(ui_pb2.Rule(name="unanswered"))) + + stream = daemon.stub.Notifications(iter([ui_pb2.NotificationReply(id=0, code=ui_pb2.OK)])) + # sending an already-exhausted request stream makes the service's reader + # finish at once, like a daemon that died mid-conversation + server.drain_outbox() + try: + list(stream) + except grpc.RpcError: + pass + time.sleep(0.3) + + row = server.db.get_outbox(outbox_id) + assert row["state"] == dbmod.OUT_QUEUED, \ + "an unanswered notification must not stay in 'sent': %s" % row["state"] + + second = FakeDaemon("unix://%s" % (tmp_path / "osui.sock")) + try: + second.subscribe() + second.open_notifications() + server.drain_outbox() + got = second.wait_for(1) + assert [n.rules[0].name for n in got] == ["unanswered"] + finally: + second.close() + def test_a_second_client_cannot_take_the_socket(self, running, tmp_path): """opensnitch-ui and opensnitch-cli serve cannot both own one daemon.""" server, _ = running diff --git a/ui/tests/cli/test_review.py b/ui/tests/cli/test_review.py index f71aed911f..22c3c54744 100644 --- a/ui/tests/cli/test_review.py +++ b/ui/tests/cli/test_review.py @@ -161,6 +161,25 @@ def test_cancelling_the_editor_goes_back(self, db, config, connection): assert applied == 0 assert db.pending_count() == 1 + def test_no_duplicate_when_the_match_becomes_a_condition(self, db, config, connection): + """add a condition, then switch the match to that same candidate. + + The rule must not require the same thing twice. + """ + queue_one(db, connection) + # e -> also require -> candidate 2 -> match on -> candidate 3 (which is + # the same object, the list shifted by removing the selected) -> apply + entry = db.pending()[0] + con = review.entry_connection(entry) + decision = review.Decision(entry, con, "allow", "always", set()) + + decision.extra.append(decision.candidates[2]) + decision.selected = decision.candidates[2] + + ops = decision.operators() + pairs = [(o.operand, o.data) for o in ops] + assert len(pairs) == len(set(pairs)), "duplicate operands: %s" % pairs + def test_a_custom_regexp_go_cannot_compile_is_refused(self, db, config, connection): queue_one(db, connection) written = [] From 5a0f249a695f2228456a090d0162adb7ea295967 Mon Sep 17 00:00:00 2001 From: Consty Date: Thu, 13 Aug 2026 22:10:33 -0700 Subject: [PATCH 17/24] cli: one approval settles everything the new rule covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approving 'allow always process.path is X' used to prompt again for every other queued destination of X, and left each entry's temporary cli-auto-* deny rule alive on the daemon — where a matching deny beats an allow (daemon/rule/loader.go FindFirstMatch), so the connections the approval was meant to allow stayed blocked for up to an hour. review now closes every queued entry a durable rule covers and withdraws their temporary rules. The covering test lives in the new cli/match.py, a deliberately conservative mirror of the daemon's operator matching: anything it cannot fully evaluate answers None and settles nothing, so a wrong guess can never silently drop a queue entry. serve now also answers AskRule with a matching decision that is still undelivered in the outbox, instead of planting a fresh temporary deny in front of it: the daemon installs the answer and persists always rules to disk itself, so a decision taken while the service was stopped takes effect the moment the program next connects. Once a decision has been confirmed delivered and the daemon asks anyway, the rule expired or was removed over there, and the connection goes back to the queue as before. While at it: review no longer claims a decision is applied within a second when no daemon is actually being served (a serve process that dies never marks its nodes offline, so the online flag is checked against the last_seen heartbeat), and two rules created in one review session can no longer be handed the same name, which made the second silently replace the first on the daemon. Co-Authored-By: Claude Fable 5 --- ui/opensnitch/cli/README.md | 13 +++- ui/opensnitch/cli/db.py | 23 ++++++ ui/opensnitch/cli/main.py | 23 +++++- ui/opensnitch/cli/match.py | 135 ++++++++++++++++++++++++++++++++++ ui/opensnitch/cli/policy.py | 54 +++++++++++++- ui/opensnitch/cli/review.py | 93 ++++++++++++++++++++---- ui/tests/cli/test_match.py | 139 ++++++++++++++++++++++++++++++++++++ ui/tests/cli/test_policy.py | 88 +++++++++++++++++++++++ ui/tests/cli/test_review.py | 111 ++++++++++++++++++++++++++++ 9 files changed, 661 insertions(+), 18 deletions(-) create mode 100644 ui/opensnitch/cli/match.py create mode 100644 ui/tests/cli/test_match.py diff --git a/ui/opensnitch/cli/README.md b/ui/opensnitch/cli/README.md index 095d379fde..29750b60da 100644 --- a/ui/opensnitch/cli/README.md +++ b/ui/opensnitch/cli/README.md @@ -180,6 +180,12 @@ Press `y`. It should say the rule was queued, and within a second `serve` should log a `DELETE_RULE` for the temporary rule followed by a `CHANGE_RULE` for the new one. +One approval can settle several entries at once: everything still in the queue +that the new rule covers is closed too, and each of their temporary rules is +withdrawn — you are not asked once per destination for the same program. Only a +rule that outlives the queue (`always`, `until restart`) settles other entries; +a temporary decision answers just the one you were shown. + **5. Confirm the rule reached the daemon.** ```bash @@ -337,7 +343,12 @@ may connect to, so it is root-only. Use `sudo`. **A rule never took effect.** `sudo opensnitch-cli status` lists rules the daemon refused, with its own error message. The usual causes are a regular expression -RE2 cannot compile and a duration Go cannot parse. +RE2 cannot compile and a duration Go cannot parse. If `notifications queued` is +not zero, the decisions simply have not been delivered yet: `serve` was not +running, or the daemon was not connected — `review` warns about this when it +finishes. Nothing is lost: they go out as soon as both are back, and until then +`serve` answers a daemon that asks about a covered connection with the decided +rule itself instead of a new temporary one. **Everything is blocked and you need to get out of it.** Set `policy.unreviewed_action = allow` and restart, or stop `opensnitch-cli` diff --git a/ui/opensnitch/cli/db.py b/ui/opensnitch/cli/db.py index 6d3f3e5bd3..5612f8aa97 100644 --- a/ui/opensnitch/cli/db.py +++ b/ui/opensnitch/cli/db.py @@ -259,6 +259,16 @@ def get_pending_by_signature(self, node, signature): return self._db.execute("SELECT * FROM pending WHERE node=? AND signature=?", (node, signature)).fetchone() + def record_hit(self, entry_id): + """another connection covered by an entry, without touching its state. + + Used when a decided entry is asked about again: unlike record_pending, + the decision stands, we only note the attempt. + """ + with self._lock: + self._db.execute("UPDATE pending SET hits=hits+1, last_seen=? WHERE id=?", + (int(time.time()), entry_id)) + def set_pending_state(self, entry_id, state, rule_json=None): with self._lock: self._db.execute( @@ -328,6 +338,19 @@ def requeue_sent(self, node=None): cur = self._db.execute(query, args) return cur.rowcount + def undelivered(self, node, ntf_type): + """notifications for a node that the daemon has not confirmed yet. + + 'sent' counts too: the stream can be open without the daemon having + answered, and an unanswered row goes back to 'queued' anyway when the + stream closes. + """ + with self._lock: + return self._db.execute( + "SELECT * FROM outbox WHERE node=? AND ntf_type=? AND state IN (?, ?) " + "ORDER BY id", + (node, ntf_type, OUT_QUEUED, OUT_SENT)).fetchall() + def get_outbox(self, outbox_id): with self._lock: return self._db.execute("SELECT * FROM outbox WHERE id=?", (outbox_id,)).fetchone() diff --git a/ui/opensnitch/cli/main.py b/ui/opensnitch/cli/main.py index 86ab0e9834..1564978e13 100644 --- a/ui/opensnitch/cli/main.py +++ b/ui/opensnitch/cli/main.py @@ -25,6 +25,7 @@ import json import logging import sys +import time from opensnitch.version import version from opensnitch.rule_consts import RuleConsts @@ -109,11 +110,29 @@ def cmd_review(args, config): applied = 0 if applied: - print("\n%d rule(s) queued. The service applies them within a second; " - "run 'opensnitch-cli status' to check." % applied) + if len(_served_nodes(db)) > 0: + print("\n%d rule(s) queued. The service applies them within a second; " + "run 'opensnitch-cli status' to check." % applied) + else: + print("\n%d rule(s) queued, but no daemon is connected right now — is " + "'opensnitch-cli serve' running? The decisions are kept, and are " + "applied as soon as the service and the daemon are back." % applied) return 0 +def _served_nodes(db, max_age=90): + """the nodes the serve service is talking to right now. + + The online flag alone can lie: a serve process that dies never marks its + nodes offline. While a node is connected its last_seen is refreshed every + 30 seconds (server.py LAST_SEEN_INTERVAL), so anything older than a couple + of those is not actually being served. + """ + now = time.time() + return [n for n in db.nodes() + if n["online"] and n["last_seen"] and now - n["last_seen"] < max_age] + + def cmd_decide(args, config): """allow / deny / reject without the interactive loop.""" from opensnitch.cli import review diff --git a/ui/opensnitch/cli/match.py b/ui/opensnitch/cli/match.py new file mode 100644 index 0000000000..04550cb976 --- /dev/null +++ b/ui/opensnitch/cli/match.py @@ -0,0 +1,135 @@ +# Copyright (C) 2026 The OpenSnitch Authors +# +# This file is part of OpenSnitch. +# +# OpenSnitch is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenSnitch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenSnitch. If not, see . + +"""Whether a rule covers a connection, decided on our side. + +The daemon is the authority on matching (daemon/rule/operator.go); this mirrors +just enough of it to answer "would the daemon still ask about this connection +if that rule were installed?". Review uses it to close queue entries a freshly +approved rule already covers, and the service uses it to answer a daemon asking +about a connection whose decision is still waiting in the outbox. + +The two possible mistakes cost very different amounts. Missing a match only +means one extra question. Claiming a match the daemon would not see silently +throws away the chance to review a connection, so everything this module does +not fully understand — an operand the CLI cannot produce, a pattern Go might +compile differently — is answered with None ("can't tell"), never guessed at. +""" + +import ipaddress +import re + +from opensnitch.rule_consts import RuleConsts + +# operand -> how to read the value it is compared against off a Connection. +# The daemon side is the operand dispatch in operator.go Match(). +_SIMPLE_VALUES = { + RuleConsts.OPERAND_PROCESS_PATH: lambda con: con.process_path, + RuleConsts.OPERAND_PROCESS_COMMAND: lambda con: " ".join(con.process_args), + RuleConsts.OPERAND_PROCESS_ID: lambda con: str(con.process_id), + RuleConsts.OPERAND_USER_ID: lambda con: str(con.user_id), + RuleConsts.OPERAND_DEST_HOST: lambda con: con.dst_host, + RuleConsts.OPERAND_DEST_IP: lambda con: con.dst_ip, + RuleConsts.OPERAND_DEST_PORT: lambda con: str(con.dst_port), + RuleConsts.OPERAND_PROTOCOL: lambda con: con.protocol, +} + +_HASH_OPERANDS = (RuleConsts.OPERAND_PROCESS_HASH_MD5, + RuleConsts.OPERAND_PROCESS_HASH_SHA1) + + +def _connection_value(operand, con): + getter = _SIMPLE_VALUES.get(operand) + if getter is None: + return None + return getter(con) + + +def _simple(op, con): + if op.operand in _HASH_OPERANDS: + # the daemon compares against every checksum it computed. When it has + # none, or checksums are disabled, it fakes a match — we can't know + # which from here, so only an actual equality is an answer. + if op.data != "" and op.data in dict(con.process_checksums).values(): + return True + return None + + value = _connection_value(op.operand, con) + if value is None: + return None + if op.sensitive: + return value == op.data + # simpleCmp uses strings.EqualFold by default + return value.lower() == op.data.lower() + + +def _regexp(op, con): + value = _connection_value(op.operand, con) + if value is None: + return None + pattern = op.data + if not op.sensitive: + # the daemon lowercases both sides (operator.go Compile / reCmp) + pattern = pattern.lower() + value = value.lower() + try: + # Go's MatchString is a search, not a full match + return re.search(pattern, value) is not None + except re.error: + return None + + +def _network(op, con): + if op.operand != RuleConsts.OPERAND_DEST_NETWORK: + return None + try: + # a network alias instead of a CIDR only resolves on the daemon + return ipaddress.ip_address(con.dst_ip) in ipaddress.ip_network(op.data) + except ValueError: + return None + + +def operator_matches(op, con): + """True or False when the daemon's verdict is knowable, None when it isn't.""" + if op.operand == "true": + return True + + if op.type == RuleConsts.RULE_TYPE_LIST: + # every operand of a list rule has to match (operator.go listMatch) + verdicts = [operator_matches(child, con) for child in op.list] + if False in verdicts: + return False + if None in verdicts or len(verdicts) == 0: + return None + return True + + if op.type == RuleConsts.RULE_TYPE_SIMPLE: + return _simple(op, con) + if op.type == RuleConsts.RULE_TYPE_REGEXP: + return _regexp(op, con) + if op.type == RuleConsts.RULE_TYPE_NETWORK: + return _network(op, con) + + # lists, range, and whatever the future adds: the daemon knows, we don't + return None + + +def rule_matches(rule, con): + """True or False when the daemon's verdict is knowable, None when it isn't.""" + if not rule.enabled: + return False + return operator_matches(rule.operator, con) diff --git a/ui/opensnitch/cli/policy.py b/ui/opensnitch/cli/policy.py index 2aafc24654..bccc738a02 100644 --- a/ui/opensnitch/cli/policy.py +++ b/ui/opensnitch/cli/policy.py @@ -31,7 +31,7 @@ import logging from opensnitch.rule_consts import RuleConsts -from opensnitch.cli import durations, rules +from opensnitch.cli import durations, match, rules PROVISIONAL_PREFIX = "cli-auto-" @@ -137,6 +137,16 @@ def on_ask(self, node, con): """ try: sig = signature(node, con) + + decided = self._decided_answer(node, con) + if decided is not None: + # keep "seen Nx / last seen" honest if this connection has a + # queue entry, without reopening it + existing = self._db.get_pending_by_signature(node, sig) + if existing is not None: + self._db.record_hit(existing["id"]) + return decided + rule = build_provisional(con, sig, self._action, self._duration) if rule is None: logger.warning("connection with no process and no destination, " @@ -170,3 +180,45 @@ def on_ask(self, node, con): except Exception as e: logger.error("error handling AskRule, letting the daemon decide: %s", repr(e)) return None + + def _decided_answer(self, node, con): + """the reviewed rule for a connection the daemon doesn't know about yet. + + Between a decision being taken and the outbox delivering it, the daemon + still asks: review may have decided this very connection while 'serve' + was stopped, or approved a rule broad enough to cover it. Answering with + the decided rule applies the decision right now — the daemon installs + what we answer, and writes it to disk itself when the duration is + always. Answering with a fresh provisional rule instead would put a + temporary deny in front of an approved allow, and the daemon lets any + matching deny beat an allow (daemon/rule/loader.go FindFirstMatch). + + Only undelivered decisions are looked at, on purpose: once the daemon + has confirmed a rule and asks anyway, the rule expired or was removed + over there, and the connection belongs back in the review queue. + + Never raises, and answers None when in doubt: the provisional flow is + the safe fallback. + """ + from google.protobuf import json_format + from opensnitch.cli.proto import ui_pb2 + + try: + allow = None + for row in self._db.undelivered(node, ui_pb2.CHANGE_RULE): + rule = ui_pb2.Rule() + try: + json_format.Parse(row["rule_json"], rule) + except Exception: + continue + if match.rule_matches(rule, con) is not True: + continue + # same tie break the daemon applies: a deny beats an allow + if rule.action in (RuleConsts.ACTION_DENY, RuleConsts.ACTION_REJECT): + return rule + if allow is None: + allow = rule + return allow + except Exception as e: + logger.warning("could not check for an undelivered decision: %s", repr(e)) + return None diff --git a/ui/opensnitch/cli/review.py b/ui/opensnitch/cli/review.py index ce1d90f85a..c6e9ee1654 100644 --- a/ui/opensnitch/cli/review.py +++ b/ui/opensnitch/cli/review.py @@ -27,7 +27,7 @@ from opensnitch import operands from opensnitch.rule_consts import RuleConsts -from opensnitch.cli import durations, rules +from opensnitch.cli import durations, match, rules SEPARATOR = "─" * 62 @@ -283,6 +283,23 @@ def edit_menu(decision, read, write): write(" ?") +def _queue_provisional_delete(db, entry): + """withdraws the temporary rule an entry was answered with.""" + from google.protobuf import json_format + from opensnitch.cli.proto import ui_pb2 + + if not entry["provisional_name"]: + return + stale = ui_pb2.Rule(name=entry["provisional_name"]) + # the daemon only reads the name of the rule to delete, but it refuses a + # rule without an operator, so give it one + stale.operator.type = RuleConsts.RULE_TYPE_SIMPLE + stale.operator.operand = "true" + stale.operator.data = "" + db.queue_notification(entry["node"], ui_pb2.DELETE_RULE, + json_format.MessageToJson(stale), pending_id=entry["id"]) + + def apply_decision(db, entry, rule): """queues the rule for the service to send, and closes the queue entry. @@ -293,34 +310,71 @@ def apply_decision(db, entry, rule): """ from google.protobuf import json_format from opensnitch.cli.proto import ui_pb2 + from opensnitch.cli import db as dbmod - if entry["provisional_name"]: - stale = ui_pb2.Rule(name=entry["provisional_name"]) - # the daemon only reads the name of the rule to delete, but it refuses a - # rule without an operator, so give it one - stale.operator.type = RuleConsts.RULE_TYPE_SIMPLE - stale.operator.operand = "true" - stale.operator.data = "" - db.queue_notification(entry["node"], ui_pb2.DELETE_RULE, - json_format.MessageToJson(stale), pending_id=entry["id"]) - + _queue_provisional_delete(db, entry) db.queue_notification(entry["node"], ui_pb2.CHANGE_RULE, json_format.MessageToJson(rule), pending_id=entry["id"]) - from opensnitch.cli import db as dbmod - db.set_pending_state(entry["id"], dbmod.STATE_DECIDED, json_format.MessageToJson(rule)) +# a decision that outlives the queue can settle other entries; a temporary one +# leaves them pending, the daemon will ask about them again anyway +DURABLE_DURATIONS = (RuleConsts.DURATION_ALWAYS, RuleConsts.DURATION_UNTIL_RESTART) + + +def resolve_covered(db, node, rule, entries): + """closes the queue entries an approved rule already covers. + + Whoever approves 'allow always process.path is X' has answered every queued + connection of X, so prompting again for each destination is noise. And + worse than noise: every entry keeps its own temporary deny rule alive on + the daemon, and the daemon lets any matching deny beat an allow + (daemon/rule/loader.go FindFirstMatch), so the connections the new rule was + meant to allow would stay blocked until those expire. Withdraw them and + mark the entries decided by this rule. + + Returns the entries that were closed. + """ + from google.protobuf import json_format + from opensnitch.cli import db as dbmod + + if rule.duration not in DURABLE_DURATIONS: + return [] + + rule_json = json_format.MessageToJson(rule) + covered = [] + for entry in entries: + if entry["node"] != node: + continue + if match.rule_matches(rule, entry_connection(entry)) is not True: + continue + _queue_provisional_delete(db, entry) + db.set_pending_state(entry["id"], dbmod.STATE_DECIDED, rule_json) + covered.append(entry) + return covered + + def review_loop(db, entries, config, read=input, write=print): """walks the queue. Returns the number of rules queued for the daemon.""" default_duration = RuleConsts.DURATION_ALWAYS applied = 0 total = len(entries) + resolved_ids = set() + # per node: the names already in use, plus everything named this session. + # Rule names are how rules are replaced and deleted, so a name may not be + # handed out twice even when db.rule_names can't know about it yet. + taken_by_node = {} for index, entry in enumerate(entries, start=1): + if entry["id"] in resolved_ids: + continue con = entry_connection(entry) - taken = db.rule_names(entry["node"]) + taken = taken_by_node.get(entry["node"]) + if taken is None: + taken = db.rule_names(entry["node"]) + taken_by_node[entry["node"]] = taken decision = Decision(entry, con, RuleConsts.ACTION_ALLOW, default_duration, taken) render_entry(entry, con, index, total, write) @@ -370,9 +424,20 @@ def review_loop(db, entries, config, read=input, write=print): rule = decision.build() apply_decision(db, entry, rule) applied += 1 + taken.add(rule.name) write(" queued: %s %s as '%s'" % (rule.action, rule.duration, rule.name)) for line in rules.describe_rule(rule).split("\n")[1:]: write(" %s" % line) + + covered = resolve_covered(db, entry["node"], rule, entries[index:]) + if len(covered) > 0: + write(" this rule also settles %d more queued connection(s), " + "their temporary rules are withdrawn:" % len(covered)) + for other in covered: + resolved_ids.add(other["id"]) + destination = other["dst_host"] or other["dst_ip"] or "?" + write(" %s -> %s:%s" % (other["process_path"] or "(unknown process)", + destination, other["dst_port"])) break return applied diff --git a/ui/tests/cli/test_match.py b/ui/tests/cli/test_match.py new file mode 100644 index 0000000000..18a98b6722 --- /dev/null +++ b/ui/tests/cli/test_match.py @@ -0,0 +1,139 @@ +# +# pytest -v cli/test_match.py +# +# match.py answers "would the daemon still ask about this connection if that +# rule were installed?". A false positive silently loses a queue entry, so the +# tests care most about the cases that must answer None ("can't tell"). +# + +from opensnitch.cli import match, rules +from opensnitch.cli.proto import ui_pb2 + + +def op(op_type, operand, data, sensitive=False): + return rules.new_operator(op_type, operand, data, sensitive=sensitive) + + +class TestSimple: + + def test_the_executable_matches(self, connection): + assert match.operator_matches( + op("simple", "process.path", "/usr/bin/curl"), connection) is True + + def test_a_different_executable_does_not(self, connection): + assert match.operator_matches( + op("simple", "process.path", "/usr/bin/wget"), connection) is False + + def test_not_case_sensitive_by_default(self, connection): + """the daemon compares with strings.EqualFold unless sensitive is set.""" + assert match.operator_matches( + op("simple", "dest.host", "API.GITHUB.COM"), connection) is True + assert match.operator_matches( + op("simple", "dest.host", "API.GITHUB.COM", sensitive=True), + connection) is False + + def test_ports_and_users_are_compared_as_strings(self, connection): + assert match.operator_matches(op("simple", "dest.port", "443"), connection) is True + assert match.operator_matches(op("simple", "user.id", "1000"), connection) is True + assert match.operator_matches(op("simple", "dest.port", "80"), connection) is False + + def test_the_command_line_is_the_joined_argv(self, connection): + assert match.operator_matches( + op("simple", "process.command", + "/usr/bin/curl -sSL https://api.github.com/repos"), connection) is True + + def test_an_operand_we_cannot_read_is_unknown(self, connection): + """iface.out lives on the daemon's packet, not on the connection.""" + assert match.operator_matches(op("simple", "iface.out", "eth0"), connection) is None + + def test_a_checksum_only_matches_when_it_is_ours(self, connection): + """without a checksum the daemon fakes a match; we can't tell, so None.""" + assert match.operator_matches( + op("simple", "process.hash.md5", "d41d8..."), connection) is None + + connection.process_checksums["process.hash.md5"] = "abc123" + assert match.operator_matches( + op("simple", "process.hash.md5", "abc123"), connection) is True + assert match.operator_matches( + op("simple", "process.hash.md5", "otherhash"), connection) is None + + +class TestRegexp: + + def test_the_host_wildcard_covers_subdomains_and_the_bare_domain(self, connection): + wildcard = op("regexp", "dest.host", r"^(|.*\.)github\.com$") + assert match.operator_matches(wildcard, connection) is True + + connection.dst_host = "github.com" + assert match.operator_matches(wildcard, connection) is True + + connection.dst_host = "notgithub.com" + assert match.operator_matches(wildcard, connection) is False + + def test_lowercased_like_the_daemon_when_not_sensitive(self, connection): + assert match.operator_matches( + op("regexp", "dest.host", r"API\.GITHUB\.com"), connection) is True + + def test_a_pattern_that_does_not_compile_is_unknown(self, connection): + assert match.operator_matches( + op("regexp", "dest.host", "("), connection) is None + + +class TestNetwork: + + def test_the_destination_network(self, connection): + assert match.operator_matches( + op("network", "dest.network", "140.82.0.0/16"), connection) is True + assert match.operator_matches( + op("network", "dest.network", "10.0.0.0/8"), connection) is False + + def test_an_alias_only_resolves_on_the_daemon(self, connection): + assert match.operator_matches( + op("network", "dest.network", "lan"), connection) is None + + +class TestList: + + def test_every_operand_has_to_match(self, connection): + rule = rules.build_rule("r", "allow", "always", [ + op("simple", "process.path", "/usr/bin/curl"), + op("simple", "dest.port", "443")]) + assert match.rule_matches(rule, connection) is True + + def test_one_mismatch_settles_it(self, connection): + rule = rules.build_rule("r", "allow", "always", [ + op("simple", "process.path", "/usr/bin/curl"), + op("simple", "dest.port", "80")]) + assert match.rule_matches(rule, connection) is False + + def test_one_unknown_spoils_the_whole_list(self, connection): + """a mismatch elsewhere still decides, but 'all matched' can't be claimed.""" + rule = rules.build_rule("r", "allow", "always", [ + op("simple", "process.path", "/usr/bin/curl"), + op("simple", "iface.out", "eth0")]) + assert match.rule_matches(rule, connection) is None + + rule = rules.build_rule("r", "allow", "always", [ + op("simple", "process.path", "/usr/bin/wget"), + op("simple", "iface.out", "eth0")]) + assert match.rule_matches(rule, connection) is False + + +class TestRule: + + def test_a_disabled_rule_matches_nothing(self, connection): + rule = rules.build_rule("r", "allow", "always", + [op("simple", "process.path", "/usr/bin/curl")], + enabled=False) + assert match.rule_matches(rule, connection) is False + + def test_the_true_operand_matches_everything(self, connection): + rule = ui_pb2.Rule(name="r", enabled=True, action="allow", duration="always") + rule.operator.type = "simple" + rule.operator.operand = "true" + assert match.rule_matches(rule, connection) is True + + def test_a_type_we_do_not_understand_is_unknown(self, connection): + rule = rules.build_rule("r", "allow", "always", + [op("lists", "lists.domains", "/etc/lists")]) + assert match.rule_matches(rule, connection) is None diff --git a/ui/tests/cli/test_policy.py b/ui/tests/cli/test_policy.py index 3984ec9389..bec0f20bdb 100644 --- a/ui/tests/cli/test_policy.py +++ b/ui/tests/cli/test_policy.py @@ -138,3 +138,91 @@ def test_full_queue_still_answers(self, db, config, connection): def test_never_raises(self, db, config): p = policy.Policy(db, config) assert p.on_ask("unix:/local", object()) is None + + +class TestUndeliveredDecisions: + """a decision taken while the daemon was away is applied when it asks again. + + Until the outbox has delivered a decision, answering with a fresh temporary + deny would put it in front of an approved allow — and the daemon lets any + matching deny beat an allow (daemon/rule/loader.go FindFirstMatch). + """ + + def _decide(self, db, config, connection, action="allow", duration="always"): + from opensnitch.cli import review + + p = policy.Policy(db, config) + p.on_ask("unix:/local", connection) + entry = db.pending()[0] + con = review.entry_connection(entry) + decision = review.Decision(entry, con, action, duration, set()) + review.apply_decision(db, entry, decision.build()) + return p + + def _deliver_everything(self, db): + for row in db.queued_notifications(): + db.mark_sent(row["id"], row["id"]) + db.mark_result(row["id"], True) + + def test_asking_again_gets_the_decision_not_a_new_provisional(self, db, config, + connection): + p = self._decide(db, config, connection) + + rule = p.on_ask("unix:/local", connection) + + assert rule.action == "allow" + assert rule.duration == "always" + # the decision stands, the entry is not reopened + assert db.pending_count() == 0 + + def test_the_attempt_still_counts(self, db, config, connection): + p = self._decide(db, config, connection) + p.on_ask("unix:/local", connection) + + entry = db.pending(state="decided")[0] + assert entry["hits"] == 2 + + def test_a_broad_decision_covers_a_new_destination(self, db, config, connection): + """allow-always on the executable answers its other destinations too.""" + p = self._decide(db, config, connection) + + other = make_connection(dst_host="pypi.org", dst_ip="151.101.0.223") + rule = p.on_ask("unix:/local", other) + + assert rule.action == "allow" + # covered, not queued: reviewing it again would be the duplicate-prompt + # problem all over + assert db.pending_count() == 0 + + def test_an_undelivered_deny_beats_an_undelivered_allow(self, db, config, + connection): + from google.protobuf import json_format + p = self._decide(db, config, connection) + deny = rules.build_rule( + "deny-curl", "deny", "always", + [rules.new_operator("simple", "process.path", "/usr/bin/curl")]) + db.queue_notification("unix:/local", ui_pb2.CHANGE_RULE, + json_format.MessageToJson(deny)) + + assert p.on_ask("unix:/local", connection).action == "deny" + + def test_a_delivered_decision_is_the_daemons_business_again(self, db, config, + connection): + """once the daemon confirmed the rule and still asks, it expired or was + removed over there: back to the provisional flow and the queue.""" + p = self._decide(db, config, connection, duration="1h") + self._deliver_everything(db) + + rule = p.on_ask("unix:/local", connection) + + assert rule.action == "deny" + assert rule.name.startswith(policy.PROVISIONAL_PREFIX) + assert db.pending_count() == 1 + + def test_another_nodes_decision_does_not_leak(self, db, config, connection): + self._decide(db, config, connection) + + p2 = policy.Policy(db, config) + rule = p2.on_ask("tcp:10.0.0.7:12345", connection) + + assert rule.name.startswith(policy.PROVISIONAL_PREFIX) diff --git a/ui/tests/cli/test_review.py b/ui/tests/cli/test_review.py index 22c3c54744..cdcebef660 100644 --- a/ui/tests/cli/test_review.py +++ b/ui/tests/cli/test_review.py @@ -110,6 +110,117 @@ def test_no_provisional_rule_means_no_delete(self, db, config, connection): assert [t for t, _ in sent_rules(db)] == [ui_pb2.CHANGE_RULE] +class TestCoveredEntries: + """one approval settles every queued connection the new rule covers. + + Anything less prompts once per destination for the same program, and — + worse — leaves each entry's temporary deny rule alive on the daemon, where + a matching deny beats the freshly approved allow (daemon/rule/loader.go + FindFirstMatch). + """ + + def _queue_for(self, db, sig, host, path="/usr/lib/apt/methods/http"): + con = ui_pb2.Connection(protocol="udp", dst_ip="127.0.0.53", dst_host=host, + dst_port=53, user_id=42, process_id=9535, + process_path=path, process_args=[path]) + db.record_pending("unix:/local", sig, con, + {"name": "cli-auto-%s" % sig, "action": "deny", + "duration": "1h", "expires_in": 3600}) + + def test_identical_entries_are_not_asked_about_again(self, db, config): + for i, host in enumerate(("archive.ubuntu.com", "security.ubuntu.com", + "packages.linuxmint.com")): + self._queue_for(db, "sig%d" % i, host) + + prompts = [] + + def read(prompt): + prompts.append(prompt) + return "y" + + applied = review.review_loop(db, db.pending(), config, read=read, write=silent) + + assert applied == 1 + assert len(prompts) == 1 + assert db.pending_count() == 0 + + def test_the_covered_temporary_rules_are_withdrawn(self, db, config): + for i, host in enumerate(("archive.ubuntu.com", "security.ubuntu.com")): + self._queue_for(db, "sig%d" % i, host) + + review.review_loop(db, db.pending(), config, read=scripted(["y"]), write=silent) + + deletes = [rule["name"] for t, rule in sent_rules(db) if t == ui_pb2.DELETE_RULE] + assert deletes == ["cli-auto-sig0", "cli-auto-sig1"] + changes = [rule for t, rule in sent_rules(db) if t == ui_pb2.CHANGE_RULE] + assert len(changes) == 1 + + def test_a_different_program_is_still_asked(self, db, config): + self._queue_for(db, "sig0", "archive.ubuntu.com") + self._queue_for(db, "sig1", "connectivity-check.ubuntu.com", + path="/usr/sbin/NetworkManager") + + applied = review.review_loop(db, db.pending(), config, + read=scripted(["y", "y"]), write=silent) + + assert applied == 2 + assert db.pending_count() == 0 + + def test_a_temporary_decision_settles_nothing(self, db, config): + """a 1h allow answers this entry, not the queue: the others come back.""" + self._queue_for(db, "sig0", "archive.ubuntu.com") + self._queue_for(db, "sig1", "security.ubuntu.com") + + # e -> duration -> 1h -> apply, then skip the second entry + answers = ["e", "3", "5", "a", "s"] + applied = review.review_loop(db, db.pending(), config, + read=scripted(answers), write=silent) + + assert applied == 1 + assert db.pending_count() == 1 + + def test_a_narrowed_rule_only_settles_what_it_covers(self, db, config): + """requiring the host too must keep the other destinations in the queue.""" + self._queue_for(db, "sig0", "archive.ubuntu.com") + self._queue_for(db, "sig1", "security.ubuntu.com") + + # e -> also require -> this host -> apply, then skip the second entry + answers = ["e", "5", "2", "a", "s"] + applied = review.review_loop(db, db.pending(), config, + read=scripted(answers), write=silent) + + assert applied == 1 + assert db.pending_count() == 1 + + def test_the_user_is_told_what_was_settled(self, db, config): + self._queue_for(db, "sig0", "archive.ubuntu.com") + self._queue_for(db, "sig1", "security.ubuntu.com") + + written = [] + review.review_loop(db, db.pending(), config, read=scripted(["y"]), + write=written.append) + + text = "\n".join(written) + assert "settles 1 more" in text + assert "security.ubuntu.com" in text + + def test_two_rules_in_one_session_cannot_share_a_name(self, db, config): + """db.rule_names can't know the names handed out this session. + + Both entries are narrowed to their host, so neither covers the other + and both rules derive their name from the same executable. + """ + self._queue_for(db, "sig0", "archive.ubuntu.com") + self._queue_for(db, "sig1", "security.ubuntu.com") + + answers = ["e", "5", "2", "a", "e", "5", "2", "a"] + review.review_loop(db, db.pending(), config, read=scripted(answers), write=silent) + + names = [rule["name"] for t, rule in sent_rules(db) if t == ui_pb2.CHANGE_RULE] + assert len(names) == 2 + assert len(set(names)) == 2, "both rules got the name %s" % names[0] + + class TestEditing: def test_switch_to_the_host_wildcard_and_deny_forever(self, db, config, connection): From b48014269e01b2e540ddc12a9bcd9a291a48e087 Mon Sep 17 00:00:00 2001 From: Consty Date: Fri, 14 Aug 2026 22:02:53 -0700 Subject: [PATCH 18/24] cli: fix rule name collisions, outbox starvation, TCP node identity and stuck failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects, each of which lost a decision without saying so. A rule approved in one review session could silently overwrite one approved in an earlier session. List rules were named after their first operand only, so "allow curl, also require a.com" and "... b.com" were both allow-always-list-usr-bin-curl, and the daemon replaces rules by name. The dedup against db.rule_names() did not help because that table is only refreshed on Subscribe. Name list rules after every condition, as the pop-up does, and treat every name already in the outbox for the node as taken. drain_outbox read the queue across all nodes with LIMIT 50 and skipped rows for nodes that were not connected, so fifty queued rows for a node that is down stopped a connected node's decisions from ever going out. Read the queue per connected node. peer_addr kept the daemon's ephemeral source port for TCP peers, so a daemon that reconnected showed up as a new node and everything queued for the old one was never delivered — the very case the outbox exists for. Key nodes by proto:host as the GUI does, handling IPv6 brackets properly. A rule the daemon rejected stayed in the outbox for good, so `status` exited non-zero forever with no way out. `status --retry` sends them again; `status --clear` drops them and puts their connections back in the review queue, since a rejected rule never took effect. Co-Authored-By: Claude Fable 5 --- ui/opensnitch/cli/README.md | 11 ++++-- ui/opensnitch/cli/db.py | 66 ++++++++++++++++++++++++++++++++--- ui/opensnitch/cli/main.py | 15 +++++++- ui/opensnitch/cli/review.py | 5 +-- ui/opensnitch/cli/rules.py | 15 ++++++-- ui/opensnitch/cli/server.py | 31 ++++++++-------- ui/opensnitch/cli/service.py | 16 +++++++-- ui/tests/cli/test_db.py | 39 +++++++++++++++++++++ ui/tests/cli/test_endtoend.py | 20 +++++++++++ ui/tests/cli/test_review.py | 34 ++++++++++++++++++ ui/tests/cli/test_rules.py | 6 ++++ ui/tests/cli/test_service.py | 15 ++++++-- 12 files changed, 239 insertions(+), 34 deletions(-) diff --git a/ui/opensnitch/cli/README.md b/ui/opensnitch/cli/README.md index 29750b60da..9f5c29c166 100644 --- a/ui/opensnitch/cli/README.md +++ b/ui/opensnitch/cli/README.md @@ -283,10 +283,12 @@ because the daemon lowercases it and it would never match. | `drop ID` | remove from the queue without creating a rule | | `rules [--node N] [--json]` | rules the daemon reported when it connected | | `nodes [--json]` | daemons that have connected | -| `status [--json]` | queue depth, nodes, and rules the daemon rejected | +| `status [--json] [--retry\|--clear]` | queue depth, nodes, and rules the daemon rejected | `status` exits non-zero if any rule was rejected, so it works as a monitoring -check. +check. `--retry` sends the rejected rules once more; `--clear` forgets them and +puts their connections back in the review queue, so the decision can be taken +again differently. `--match` takes an operand name, for example: @@ -343,7 +345,10 @@ may connect to, so it is root-only. Use `sudo`. **A rule never took effect.** `sudo opensnitch-cli status` lists rules the daemon refused, with its own error message. The usual causes are a regular expression -RE2 cannot compile and a duration Go cannot parse. If `notifications queued` is +RE2 cannot compile and a duration Go cannot parse. A refused rule stays listed, +and `status` keeps exiting non-zero, until you deal with it: `status --clear` +drops it and returns the connection to the review queue so you can decide it +again, `status --retry` sends it once more. If `notifications queued` is not zero, the decisions simply have not been delivered yet: `serve` was not running, or the daemon was not connected — `review` warns about this when it finishes. Nothing is lost: they go out as soon as both are back, and until then diff --git a/ui/opensnitch/cli/db.py b/ui/opensnitch/cli/db.py index 5612f8aa97..0605024d94 100644 --- a/ui/opensnitch/cli/db.py +++ b/ui/opensnitch/cli/db.py @@ -301,11 +301,21 @@ def queue_notification(self, node, ntf_type, rule_json, pending_id=None): int(time.time()), pending_id)) return cur.lastrowid - def queued_notifications(self, limit=50): + def queued_notifications(self, limit=50, node=None): + query = "SELECT * FROM outbox WHERE state=?" + args = [OUT_QUEUED] + if node is not None: + query += " AND node=?" + args.append(node) + query += " ORDER BY id LIMIT ?" + args.append(limit) with self._lock: - return self._db.execute( - "SELECT * FROM outbox WHERE state=? ORDER BY id LIMIT ?", - (OUT_QUEUED, limit)).fetchall() + return self._db.execute(query, args).fetchall() + + def queued_count(self): + with self._lock: + return self._db.execute("SELECT COUNT(*) FROM outbox WHERE state=?", + (OUT_QUEUED,)).fetchone()[0] def mark_sent(self, outbox_id, ntf_id): with self._lock: @@ -360,6 +370,34 @@ def outbox_errors(self): return self._db.execute( "SELECT * FROM outbox WHERE state=? ORDER BY id", (OUT_ERROR,)).fetchall() + def retry_errors(self): + """sends the notifications the daemon rejected once more.""" + with self._lock: + cur = self._db.execute( + "UPDATE outbox SET state=?, last_error=NULL, updated=? WHERE state=?", + (OUT_QUEUED, int(time.time()), OUT_ERROR)) + return cur.rowcount + + def clear_errors(self): + """forgets the notifications the daemon rejected. + + A rejected rule was never applied, so the connection it was meant to + decide goes back to the review queue: the decision has to be taken + again, differently. Returns the number of notifications dropped. + """ + with self._lock: + rows = self._db.execute( + "SELECT pending_id FROM outbox WHERE state=?", (OUT_ERROR,)).fetchall() + for row in rows: + if row["pending_id"] is None: + continue + self._db.execute( + "UPDATE pending SET state=?, decided_at=NULL, decided_rule=NULL " + "WHERE id=? AND state=?", + (STATE_PENDING, row["pending_id"], STATE_DECIDED)) + self._db.execute("DELETE FROM outbox WHERE state=?", (OUT_ERROR,)) + return len(rows) + # the daemon's rules, as reported on Subscribe def replace_rules(self, node, rules): @@ -383,9 +421,27 @@ def rules(self, node=None): return self._db.execute(query, args).fetchall() def rule_names(self, node): + """the names a new rule for this node must not take. + + What the daemon reported when it connected, plus every rule we have + queued for it since: the rules table is only refreshed on Subscribe, + and the daemon replaces rules by name, so a name handed out in an + earlier review session would otherwise be handed out again and the + second rule would silently overwrite the first. + """ with self._lock: rows = self._db.execute("SELECT name FROM rules WHERE node=?", (node,)).fetchall() - return set([r["name"] for r in rows]) + sent = self._db.execute("SELECT rule_json FROM outbox WHERE node=?", + (node,)).fetchall() + names = set([r["name"] for r in rows]) + for row in sent: + try: + name = json.loads(row["rule_json"]).get("name") + except (ValueError, AttributeError): + continue + if name: + names.add(name) + return names def add_alert(self, node, alert): with self._lock: diff --git a/ui/opensnitch/cli/main.py b/ui/opensnitch/cli/main.py index 1564978e13..32e2e9d674 100644 --- a/ui/opensnitch/cli/main.py +++ b/ui/opensnitch/cli/main.py @@ -216,10 +216,17 @@ def cmd_nodes(args, config): def cmd_status(args, config): db = open_db(config) + + if args.retry: + print("re-sending %d rejected notification(s)" % db.retry_errors()) + elif args.clear: + print("dropped %d rejected notification(s); their connections are back in " + "the review queue" % db.clear_errors()) + nodes = db.nodes() online = len([n for n in nodes if n["online"]]) errors = db.outbox_errors() - queued = len(db.queued_notifications(limit=1000)) + queued = db.queued_count() status = { "pending": db.pending_count(), @@ -294,6 +301,12 @@ def build_parser(): status = subparsers.add_parser("status", help="queue depth, nodes and failed rules") status.add_argument("--json", action="store_true") + failed = status.add_mutually_exclusive_group() + failed.add_argument("--retry", action="store_true", + help="send the rules the daemon rejected once more") + failed.add_argument("--clear", action="store_true", + help="forget the rules the daemon rejected and put their " + "connections back in the review queue") status.set_defaults(func=cmd_status) return parser diff --git a/ui/opensnitch/cli/review.py b/ui/opensnitch/cli/review.py index c6e9ee1654..957e4ad733 100644 --- a/ui/opensnitch/cli/review.py +++ b/ui/opensnitch/cli/review.py @@ -141,9 +141,10 @@ def name(self): ops = self.operators() if len(ops) == 0: return "" - data = ops[0].data return rules.unique_name( - rules.rule_name(self.action, self.duration, len(ops) > 1, data), self._taken) + rules.rule_name(self.action, self.duration, len(ops) > 1, ops[0].data, + [op.data for op in ops[1:]]), + self._taken) @name.setter def name(self, value): diff --git a/ui/opensnitch/cli/rules.py b/ui/opensnitch/cli/rules.py index 12e8d95c8c..a5917683ed 100644 --- a/ui/opensnitch/cli/rules.py +++ b/ui/opensnitch/cli/rules.py @@ -78,11 +78,20 @@ def build_rule(name, action, duration, ops, precedence=False, description="", en return rule -def rule_name(action, duration, is_list, data): - """same naming the pop-up uses, see dialogs/prompt/utils.py get_rule_name""" +def rule_name(action, duration, is_list, data, extra=()): + """same naming the pop-up uses, see dialogs/prompt/utils.py get_rule_name. + + Every condition of a list rule goes into the name, as the pop-up does + (dialogs/prompt/dialog.py _send_rule). Rules are replaced by name on the + daemon, so two rules for the same program that differ only in the host + they allow must not end up called the same thing. + """ name = slugify("%s %s" % (action, duration)) name = "%s-%s" % (name, "list" if is_list else "simple") - return slugify("%s %s" % (name, data))[:128] + name = slugify("%s %s" % (name, data)) + for value in extra: + name = slugify("%s %s" % (name, value)) + return name[:128] def unique_name(name, taken): diff --git a/ui/opensnitch/cli/server.py b/ui/opensnitch/cli/server.py index 2125023b60..9335df767e 100644 --- a/ui/opensnitch/cli/server.py +++ b/ui/opensnitch/cli/server.py @@ -226,24 +226,25 @@ def drain_outbox(self): """sends the decisions taken by 'opensnitch-cli review'. Rows for a node that isn't connected stay queued, so a decision taken - while the daemon is down is applied when it comes back. + while the daemon is down is applied when it comes back. The queue is + read per connected node: one query across all nodes would let a + disconnected node's backlog fill the batch and starve the others. """ sent = 0 - for row in self._db.queued_notifications(): - node = self._service.get_node(row["node"]) - if node is None or node.stop.is_set(): + for node in self._service.nodes(): + if node.stop.is_set(): continue - - rule = ui_pb2.Rule() - json_format.Parse(row["rule_json"], rule) - - ntf_id = self._next_notification_id() - notification = ui_pb2.Notification(id=ntf_id, type=row["ntf_type"], rules=[rule]) - self._db.mark_sent(row["id"], ntf_id) - node.queue.put(notification) - sent += 1 - logger.info("sent %s for rule '%s' to %s", - ui_pb2.Action.Name(row["ntf_type"]), rule.name, row["node"]) + for row in self._db.queued_notifications(node=node.addr): + rule = ui_pb2.Rule() + json_format.Parse(row["rule_json"], rule) + + ntf_id = self._next_notification_id() + notification = ui_pb2.Notification(id=ntf_id, type=row["ntf_type"], rules=[rule]) + self._db.mark_sent(row["id"], ntf_id) + node.queue.put(notification) + sent += 1 + logger.info("sent %s for rule '%s' to %s", + ui_pb2.Action.Name(row["ntf_type"]), rule.name, row["node"]) return sent def _outbox_loop(self): diff --git a/ui/opensnitch/cli/service.py b/ui/opensnitch/cli/service.py index 9b83131921..c74f9c559e 100644 --- a/ui/opensnitch/cli/service.py +++ b/ui/opensnitch/cli/service.py @@ -68,13 +68,23 @@ def __init__(self, db, policy, config): def peer_addr(self, peer): """the key we store a node under. - Same shape the GUI uses (opensnitch/nodes.py get_addr): "proto:address", - with a placeholder for unix sockets, whose peer has no address. + Same shape the GUI uses (opensnitch/nodes.py get_addr): "proto:host", + with a placeholder for unix sockets, whose peer has no address. The + port is dropped on purpose: over TCP the peer is the daemon's ephemeral + source port, which changes every time it reconnects, and the outbox is + keyed by this value — keeping the port would leave every decision taken + while the daemon was down addressed to a node that never comes back. """ proto, _, addr = peer.partition(":") if proto.startswith("unix"): return "%s:%s" % (proto, addr if addr != "" else "/local") - return "%s:%s" % (proto, addr) + if addr.startswith("["): + # ipv6:[::1]:59680 + host = addr[:addr.find("]") + 1] if "]" in addr else addr + else: + # ipv4:192.168.1.5:41000 + host = addr.rsplit(":", 1)[0] if ":" in addr else addr + return "%s:%s" % (proto, host) def get_node(self, addr): with self._lock: diff --git a/ui/tests/cli/test_db.py b/ui/tests/cli/test_db.py index 3d14e8be40..c0fd1f4c5c 100644 --- a/ui/tests/cli/test_db.py +++ b/ui/tests/cli/test_db.py @@ -93,6 +93,45 @@ def test_a_decision_survives_the_service_being_down(self, db): db.queue_notification("n", 10, '{"name":"r"}') assert len(db.queued_notifications()) == 1 + def test_the_queue_can_be_read_per_node(self, db): + for i in range(60): + db.queue_notification("dead", 10, "{}") + db.queue_notification("live", 10, '{"name":"r"}') + + assert len(db.queued_notifications(node="live")) == 1 + assert db.queued_count() == 61 + + def test_a_rejected_rule_can_be_sent_again(self, db): + outbox_id = db.queue_notification("n", 10, "{}") + db.mark_sent(outbox_id, 558) + db.mark_result(558, False, "invalid regexp") + + assert db.retry_errors() == 1 + row = db.get_outbox(outbox_id) + assert row["state"] == dbmod.OUT_QUEUED + assert row["last_error"] is None + + def test_clearing_a_rejected_rule_reopens_its_queue_entry(self, db, connection): + """the decision never took, so it has to be taken again.""" + entry_id, _ = db.record_pending("n", "sig", connection, {}) + db.set_pending_state(entry_id, dbmod.STATE_DECIDED, '{"name":"bad"}') + outbox_id = db.queue_notification("n", 10, '{"name":"bad"}', pending_id=entry_id) + db.mark_sent(outbox_id, 559) + db.mark_result(559, False, "invalid regexp") + + assert db.clear_errors() == 1 + assert db.outbox_errors() == [] + assert db.get_pending(entry_id)["state"] == dbmod.STATE_PENDING + assert db.get_pending(entry_id)["decided_rule"] is None + + def test_names_handed_out_earlier_are_taken(self, db): + """the rules table only knows what the daemon reported on connecting; + what we sent since must not be handed out again.""" + db.queue_notification("n", 10, '{"name":"allow-always-list-usr-bin-curl"}') + assert "allow-always-list-usr-bin-curl" in db.rule_names("n") + assert "allow-always-list-usr-bin-curl" not in db.rule_names("other") + + class TestConcurrentAccess: diff --git a/ui/tests/cli/test_endtoend.py b/ui/tests/cli/test_endtoend.py index d85c69c0fa..c834528f04 100644 --- a/ui/tests/cli/test_endtoend.py +++ b/ui/tests/cli/test_endtoend.py @@ -185,6 +185,26 @@ def test_a_decision_reaches_the_daemon_in_order(self, running): assert got[1].rules[0].name == "allow-always-simple-github" assert got[1].rules[0].operator.data == r"^(|.*\.)github\.com$" + def test_a_disconnected_nodes_backlog_does_not_starve_the_others(self, running): + """decisions for a node that is down stay queued; they must not stop + the decisions for a node that is up from going out.""" + server, daemon = running + daemon.subscribe() + daemon.open_notifications() + + for i in range(60): + server.db.queue_notification( + "ipv4:10.0.0.9", ui_pb2.CHANGE_RULE, + json_format.MessageToJson(ui_pb2.Rule(name="down-%d" % i))) + server.db.queue_notification( + "unix:/local", ui_pb2.CHANGE_RULE, + json_format.MessageToJson(ui_pb2.Rule(name="up"))) + + assert server.drain_outbox() == 1 + got = daemon.wait_for(1) + assert [n.rules[0].name for n in got] == ["up"] + assert server.db.queued_count() == 60 + def test_the_daemons_answer_is_recorded(self, running): server, daemon = running daemon.subscribe() diff --git a/ui/tests/cli/test_review.py b/ui/tests/cli/test_review.py index cdcebef660..56b7cebcd6 100644 --- a/ui/tests/cli/test_review.py +++ b/ui/tests/cli/test_review.py @@ -220,6 +220,40 @@ def test_two_rules_in_one_session_cannot_share_a_name(self, db, config): assert len(names) == 2 assert len(set(names)) == 2, "both rules got the name %s" % names[0] + def test_a_list_rule_is_named_after_all_of_its_conditions(self, db, config): + """as the pop-up does: the host is part of the name, so two rules that + allow the same program to different hosts don't collide to begin with.""" + self._queue_for(db, "sig0", "archive.ubuntu.com") + + review.review_loop(db, db.pending(), config, + read=scripted(["e", "5", "2", "a"]), write=silent) + + (name,) = [rule["name"] for t, rule in sent_rules(db) if t == ui_pb2.CHANGE_RULE] + assert name == "allow-always-list-usr-lib-apt-methods-http-archive-ubuntu-com" + + def test_two_rules_in_different_sessions_cannot_share_a_name(self, db, config): + """the daemon replaces rules by name, and only tells us its rule names + when it connects. A name handed out by an earlier review must not be + handed out again by a later one, or the first rule is silently lost.""" + self._queue_for(db, "sig0", "archive.ubuntu.com") + self._queue_for(db, "sig1", "security.ubuntu.com") + first, second = db.pending() + + # two separate review runs, each starting from db.rule_names, and each + # narrowing to the same *custom* pattern so the names would otherwise + # be identical + for entry in (first, second): + con = review.entry_connection(entry) + decision = review.Decision(entry, con, "allow", "always", + db.rule_names(entry["node"])) + decision.extra = [{"label": "custom", "type": "regexp", + "operand": "dest.host", "data": r".*\.ubuntu\.com$"}] + review.apply_decision(db, entry, decision.build()) + + names = [rule["name"] for t, rule in sent_rules(db) if t == ui_pb2.CHANGE_RULE] + assert len(names) == 2 + assert len(set(names)) == 2, "both rules got the name %s" % names[0] + class TestEditing: diff --git a/ui/tests/cli/test_rules.py b/ui/tests/cli/test_rules.py index 0bbeff6253..7e38051354 100644 --- a/ui/tests/cli/test_rules.py +++ b/ui/tests/cli/test_rules.py @@ -62,6 +62,12 @@ def test_name_matches_the_popup_convention(self): assert rules.rule_name("allow", "always", False, "/usr/bin/curl") == \ "allow-always-simple-usr-bin-curl" + def test_a_list_rule_is_named_after_every_condition(self): + """also the pop-up's convention (dialogs/prompt/dialog.py _send_rule)""" + assert rules.rule_name("allow", "always", True, "/usr/bin/curl", + ["api.github.com", "443"]) == \ + "allow-always-list-usr-bin-curl-api-github-com-443" + def test_unique_name_avoids_a_rename_by_the_daemon(self): taken = {"allow-always-simple-x", "allow-always-simple-x-2"} assert rules.unique_name("allow-always-simple-x", taken) == "allow-always-simple-x-3" diff --git a/ui/tests/cli/test_service.py b/ui/tests/cli/test_service.py index f15bfeb1de..c3bb62e810 100644 --- a/ui/tests/cli/test_service.py +++ b/ui/tests/cli/test_service.py @@ -55,9 +55,20 @@ def test_unix_socket_has_no_address(self, db, config): service = make_service(db, config) assert service.peer_addr("unix:") == "unix:/local" - def test_tcp(self, db, config): + def test_tcp_drops_the_ephemeral_port(self, db, config): + """the daemon dials from a new source port on every reconnect; if it + were part of the key, decisions queued while it was down would be + addressed to a node that never returns.""" service = make_service(db, config) - assert service.peer_addr("ipv4:192.168.1.5:12345") == "ipv4:192.168.1.5:12345" + assert service.peer_addr("ipv4:192.168.1.5:12345") == "ipv4:192.168.1.5" + assert service.peer_addr("ipv4:192.168.1.5:12345") == \ + service.peer_addr("ipv4:192.168.1.5:54321") + + def test_ipv6(self, db, config): + service = make_service(db, config) + assert service.peer_addr("ipv6:[::1]:59680") == "ipv6:[::1]" + assert service.peer_addr("ipv6:[fe80::1%eth0]:1") == "ipv6:[fe80::1%eth0]" + class TestSubscribe: From 5e237197ed8b39edd806d172c90306a710d62ae1 Mon Sep 17 00:00:00 2001 From: Consty Date: Fri, 14 Aug 2026 22:04:07 -0700 Subject: [PATCH 19/24] cli: add rule delete, enable and disable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the rules a daemon has could only be listed. Once one was wrong there was no way to remove it short of deleting its file by hand, and no way at all for an "until restart" rule other than restarting the daemon. opensnitch-cli rule delete NAME [--node N] opensnitch-cli rule enable NAME opensnitch-cli rule disable NAME go through the outbox like a decision from review, so they survive serve being down. With one node connected --node is implied. Enable and disable send the whole rule back, not just its name. The daemon's handler deserializes what it is given and replaces the rule with it (daemon/ui/notifications.go handleActionEnableRule), and NewOperator never fails, so a name alone would leave the daemon holding an empty rule: disabled only until it restarts and reloads the file, and never enabled again because an empty operator does not compile. That is what the GUI's events view sends. So the whole rule the daemon reported is now kept (schema 2, rules.rule_json, with an in-place upgrade for existing queues), and every change the daemon confirms — from review or from here — is reflected into that table, which keeps `rules` accurate while serve runs instead of going stale after Subscribe. `rules` shows what each rule matches on, and `rules --json` the parsed rule. Co-Authored-By: Claude Fable 5 --- ui/opensnitch/cli/README.md | 28 +++++++- ui/opensnitch/cli/db.py | 66 ++++++++++++++++--- ui/opensnitch/cli/main.py | 118 +++++++++++++++++++++++++++++++-- ui/tests/cli/test_commands.py | 119 ++++++++++++++++++++++++++++++++++ ui/tests/cli/test_db.py | 73 +++++++++++++++++++++ 5 files changed, 387 insertions(+), 17 deletions(-) create mode 100644 ui/tests/cli/test_commands.py diff --git a/ui/opensnitch/cli/README.md b/ui/opensnitch/cli/README.md index 9f5c29c166..db9d11af95 100644 --- a/ui/opensnitch/cli/README.md +++ b/ui/opensnitch/cli/README.md @@ -281,7 +281,8 @@ because the daemon lowercases it and it would never match. | `allow ID [--match OPERAND] [--duration D] [--name N]` | approve without prompting | | `deny ID` / `reject ID` | refuse without prompting | | `drop ID` | remove from the queue without creating a rule | -| `rules [--node N] [--json]` | rules the daemon reported when it connected | +| `rules [--node N] [--json]` | the rules each daemon has | +| `rule delete\|enable\|disable NAME [--node N]` | change a rule the daemon has | | `nodes [--json]` | daemons that have connected | | `status [--json] [--retry\|--clear]` | queue depth, nodes, and rules the daemon rejected | @@ -296,6 +297,29 @@ again differently. sudo opensnitch-cli allow 3 --match dest.host --duration always ``` +## Managing the rules a daemon has + +`rules` lists them, and `rule` changes them: + +```bash +sudo opensnitch-cli rules +sudo opensnitch-cli rule disable allow-always-simple-usr-bin-curl +sudo opensnitch-cli rule enable allow-always-simple-usr-bin-curl +sudo opensnitch-cli rule delete allow-always-simple-usr-bin-curl +``` + +Like a decision from `review`, these go through the outbox: `serve` sends them +within a second, or as soon as the daemon is back. Deleting an `always` rule +removes its file from `/etc/opensnitchd/rules`; deleting a temporary one takes +it out of the daemon's memory, which is the only way to get rid of an +`until restart` rule short of restarting the daemon. + +The list starts as what the daemon reported when it connected and is kept in +step with every change the daemon confirms, so it stays accurate while `serve` +runs. It does not see rules that expire on the daemon on their own, or that +were edited on disk by hand, until the daemon reconnects. With more than one +node, `--node` says which daemon is meant; with one, it is implied. + ## Configuration `/etc/opensnitch/cli.conf`, or `~/.config/opensnitch/cli.conf`. Running with no @@ -366,6 +390,8 @@ from `/etc/opensnitchd/default-config.json`. not stored; the review queue is fed by the connections the daemon actually asks about. * No firewall (nftables) configuration. +* No rules editor beyond `rule delete|enable|disable`: to change what a rule + matches, delete it and approve a new one from the queue. * Multiple nodes are recorded and can be filtered with `--node`, but there is no per-node management beyond that. * It never blocks waiting for a person, by design. diff --git a/ui/opensnitch/cli/db.py b/ui/opensnitch/cli/db.py index 0605024d94..b5c1cd1bac 100644 --- a/ui/opensnitch/cli/db.py +++ b/ui/opensnitch/cli/db.py @@ -34,7 +34,7 @@ import threading import time -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 STATE_PENDING = "pending" STATE_DECIDED = "decided" @@ -109,6 +109,7 @@ op_operand TEXT, op_data TEXT, updated INTEGER, + rule_json TEXT, PRIMARY KEY(node, name) ); @@ -158,12 +159,16 @@ def _setup(self): self._db.executescript(SCHEMA) version = self._db.execute("PRAGMA user_version").fetchone()[0] - if version == 0: - self._db.execute("PRAGMA user_version={0}".format(SCHEMA_VERSION)) - elif version > SCHEMA_VERSION: + if version > SCHEMA_VERSION: raise RuntimeError( "{0} was created by a newer version of opensnitch-cli " "(schema {1} > {2})".format(self.path, version, SCHEMA_VERSION)) + if version == 1: + # schema 2 keeps the whole rule the daemon reported, so that + # 'rule enable/disable' can send it back complete + self._db.execute("ALTER TABLE rules ADD COLUMN rule_json TEXT") + if version != SCHEMA_VERSION: + self._db.execute("PRAGMA user_version={0}".format(SCHEMA_VERSION)) def close(self): with self._lock: @@ -330,6 +335,8 @@ def mark_result(self, ntf_id, ok, error=None): self._db.execute( "UPDATE outbox SET state=?, last_error=?, updated=? WHERE ntf_id=? AND state=?", (OUT_DONE if ok else OUT_ERROR, error, int(time.time()), ntf_id, OUT_SENT)) + if ok: + self.reflect_result(ntf_id) def requeue_sent(self, node=None): """puts unanswered notifications back in the queue. @@ -401,14 +408,53 @@ def clear_errors(self): # the daemon's rules, as reported on Subscribe def replace_rules(self, node, rules): + """the rule set a node reported on Subscribe, whole.""" + from google.protobuf import json_format + with self._lock: self._db.execute("DELETE FROM rules WHERE node=?", (node,)) - now = int(time.time()) - self._db.executemany( - "INSERT OR REPLACE INTO rules (node, name, enabled, action, duration, " - "op_type, op_operand, op_data, updated) VALUES (?,?,?,?,?,?,?,?,?)", - [(node, r.name, 1 if r.enabled else 0, r.action, r.duration, - r.operator.type, r.operator.operand, r.operator.data, now) for r in rules]) + for r in rules: + self._upsert_rule(node, r, json_format.MessageToJson(r)) + + def _upsert_rule(self, node, r, rule_json): + self._db.execute( + "INSERT OR REPLACE INTO rules (node, name, enabled, action, duration, " + "op_type, op_operand, op_data, updated, rule_json) VALUES (?,?,?,?,?,?,?,?,?,?)", + (node, r.name, 1 if r.enabled else 0, r.action, r.duration, + r.operator.type, r.operator.operand, r.operator.data, int(time.time()), + rule_json)) + + def reflect_result(self, ntf_id): + """keeps the rules table in step with a change the daemon confirmed. + + The daemon only lists its rules when it connects; after that, what we + successfully sent it is the best knowledge we have. + """ + from google.protobuf import json_format + from opensnitch.cli.proto import ui_pb2 + + with self._lock: + row = self._db.execute( + "SELECT node, ntf_type, rule_json FROM outbox WHERE ntf_id=? AND state=?", + (ntf_id, OUT_DONE)).fetchone() + if row is None: + return + rule = ui_pb2.Rule() + try: + json_format.Parse(row["rule_json"], rule) + except json_format.ParseError: + return + if row["ntf_type"] == ui_pb2.DELETE_RULE: + self._db.execute("DELETE FROM rules WHERE node=? AND name=?", + (row["node"], rule.name)) + elif row["ntf_type"] in (ui_pb2.CHANGE_RULE, ui_pb2.ENABLE_RULE, + ui_pb2.DISABLE_RULE): + self._upsert_rule(row["node"], rule, row["rule_json"]) + + def get_rule(self, node, name): + with self._lock: + return self._db.execute("SELECT * FROM rules WHERE node=? AND name=?", + (node, name)).fetchone() def rules(self, node=None): query = "SELECT * FROM rules" diff --git a/ui/opensnitch/cli/main.py b/ui/opensnitch/cli/main.py index 32e2e9d674..a2315bcc29 100644 --- a/ui/opensnitch/cli/main.py +++ b/ui/opensnitch/cli/main.py @@ -133,6 +133,30 @@ def _served_nodes(db, max_age=90): if n["online"] and n["last_seen"] and now - n["last_seen"] < max_age] +class CommandError(Exception): + """a command cannot do what was asked; the message is for the user.""" + + +def resolve_node(db, requested): + """the node a rule command applies to. + + With one node known there is nothing to choose; with several, --node has + to say which, because a rule name only means something on one daemon. + """ + known = [n["addr"] for n in db.nodes()] + if requested is not None: + if requested not in known: + raise CommandError("no node called '%s' has connected. Known: %s" % ( + requested, ", ".join(known) or "none")) + return requested + if len(known) == 1: + return known[0] + if len(known) == 0: + raise CommandError("no node has connected yet, so there is no daemon to send this to") + raise CommandError("several nodes have connected, say which with --node: %s" % + ", ".join(known)) + + def cmd_decide(args, config): """allow / deny / reject without the interactive loop.""" from opensnitch.cli import review @@ -181,20 +205,96 @@ def cmd_drop(args, config): return 0 +def _rule_match_summary(row): + """one short column describing what a stored rule matches on.""" + if row["op_type"] == RuleConsts.RULE_TYPE_LIST and row["rule_json"]: + try: + ops = json.loads(row["rule_json"]).get("operator", {}).get("list", []) + except ValueError: + ops = [] + return " and ".join("%s %s" % (o.get("operand"), o.get("data")) for o in ops) or "list" + return "%s %s %s" % (row["op_operand"], "is" if row["op_type"] == "simple" else + row["op_type"], row["op_data"]) + + def cmd_rules(args, config): db = open_db(config) entries = db.rules(node=args.node) if args.json: - print(json.dumps([dict(e) for e in entries], indent=2)) + out = [] + for e in entries: + row = dict(e) + try: + row["rule"] = json.loads(row.pop("rule_json") or "null") + except ValueError: + row["rule"] = None + out.append(row) + print(json.dumps(out, indent=2)) return 0 if len(entries) == 0: print("no rules known. They are read from each node when it connects.") return 0 - print("%-8s %-40s %-8s %s" % ("ENABLED", "NAME", "ACTION", "DURATION")) + print("%-8s %-44s %-7s %-14s %s" % ("ENABLED", "NAME", "ACTION", "DURATION", "MATCH")) for rule in entries: - print("%-8s %-40s %-8s %s" % ( - "yes" if rule["enabled"] else "no", rule["name"][:40], rule["action"], - rule["duration"])) + print("%-8s %-44s %-7s %-14s %s" % ( + "yes" if rule["enabled"] else "no", rule["name"][:44], rule["action"], + rule["duration"], _rule_match_summary(rule))) + return 0 + + +def cmd_rule(args, config): + """delete / enable / disable a rule on the daemon, by name.""" + from google.protobuf import json_format + from opensnitch.cli.proto import ui_pb2 + from opensnitch.cli import rules + + db = open_db(config) + try: + node = resolve_node(db, args.node) + except CommandError as e: + print("opensnitch-cli: %s" % e, file=sys.stderr) + return 1 + + row = db.get_rule(node, args.name) + + if args.verb == "delete": + # the daemon only reads the name, but refuses a rule with no operator + rule = ui_pb2.Rule(name=args.name) + rule.operator.type = RuleConsts.RULE_TYPE_SIMPLE + rule.operator.operand = "true" + if row is None: + print("note: '%s' is not among the rules this node reported; deleting a rule " + "the daemon does not have does nothing" % args.name, file=sys.stderr) + db.queue_notification(node, ui_pb2.DELETE_RULE, json_format.MessageToJson(rule)) + print("queued: delete rule '%s' on %s" % (args.name, node)) + return 0 + + # enable / disable send the whole rule back, because the daemon replaces + # what it has with what it receives (daemon/ui/notifications.go + # handleActionEnableRule): a name alone would leave it with an empty rule. + if row is None: + print("opensnitch-cli: no rule called '%s' is known on %s. 'opensnitch-cli rules' " + "lists them" % (args.name, node), file=sys.stderr) + return 1 + if not row["rule_json"]: + print("opensnitch-cli: the whole of '%s' is not known yet, only its name; it will " + "be once the daemon reconnects" % args.name, file=sys.stderr) + return 1 + + rule = ui_pb2.Rule() + json_format.Parse(row["rule_json"], rule) + enable = args.verb == "enable" + if bool(rule.enabled) == enable: + print("rule '%s' is already %sd" % (args.name, args.verb)) + return 0 + rule.enabled = enable + error = rules.validate_rule(rule) + if error is not None: + print("opensnitch-cli: the daemon would refuse this rule: %s" % error, file=sys.stderr) + return 1 + db.queue_notification(node, ui_pb2.ENABLE_RULE if enable else ui_pb2.DISABLE_RULE, + json_format.MessageToJson(rule)) + print("queued: %s rule '%s' on %s" % (args.verb, args.name, node)) return 0 @@ -290,11 +390,17 @@ def build_parser(): drop.add_argument("id", type=int) drop.set_defaults(func=cmd_drop) - rules_cmd = subparsers.add_parser("rules", help="rules the daemon reported on connecting") + rules_cmd = subparsers.add_parser("rules", help="the rules each daemon has") rules_cmd.add_argument("--node") rules_cmd.add_argument("--json", action="store_true") rules_cmd.set_defaults(func=cmd_rules) + rule_cmd = subparsers.add_parser("rule", help="delete, enable or disable a rule by name") + rule_cmd.add_argument("verb", choices=("delete", "enable", "disable")) + rule_cmd.add_argument("name") + rule_cmd.add_argument("--node", help="which daemon, when more than one has connected") + rule_cmd.set_defaults(func=cmd_rule) + nodes = subparsers.add_parser("nodes", help="daemons that have connected") nodes.add_argument("--json", action="store_true") nodes.set_defaults(func=cmd_nodes) diff --git a/ui/tests/cli/test_commands.py b/ui/tests/cli/test_commands.py new file mode 100644 index 0000000000..db05c621c6 --- /dev/null +++ b/ui/tests/cli/test_commands.py @@ -0,0 +1,119 @@ +# +# pytest -v cli/test_commands.py +# +# The commands of opensnitch-cli that act on the database without a daemon, +# driven the way main() drives them: through the parser and the cmd_* function. +# + +import json +import time + +from google.protobuf import json_format + +from opensnitch.cli import db as dbmod, main +from opensnitch.cli.proto import ui_pb2 + + +def run(argv, config): + args = main.build_parser().parse_args(argv) + return args.func(args, config) + + +def a_rule(name, enabled=True): + rule = ui_pb2.Rule(name=name, enabled=enabled, action="allow", duration="always") + rule.operator.type = "simple" + rule.operator.operand = "process.path" + rule.operator.data = "/usr/bin/curl" + return rule + + +def queued(db): + return [(row["ntf_type"], json.loads(row["rule_json"])) + for row in db.queued_notifications()] + + +class TestRule: + + def test_delete_queues_a_delete_for_the_daemon(self, db, config, capsys): + db.node_seen("unix:/local", "h", "1.9.0") + db.replace_rules("unix:/local", [a_rule("r")]) + + assert run(["rule", "delete", "r"], config) == 0 + + [(ntf_type, rule)] = queued(db) + assert ntf_type == ui_pb2.DELETE_RULE + assert rule["name"] == "r" + # the daemon refuses a rule without an operator, even to delete it + assert rule["operator"]["operand"] == "true" + + def test_disable_sends_the_whole_rule_back(self, db, config): + """a name alone would make the daemon replace the rule with an empty + one (notifications.go handleActionEnableRule): the real rule has to + travel with the request.""" + db.node_seen("unix:/local", "h", "1.9.0") + db.replace_rules("unix:/local", [a_rule("r")]) + + assert run(["rule", "disable", "r"], config) == 0 + + [(ntf_type, rule)] = queued(db) + assert ntf_type == ui_pb2.DISABLE_RULE + # protobuf's JSON leaves out fields at their default, so a missing + # "enabled" is false + assert rule.get("enabled", False) is False + assert rule["operator"]["data"] == "/usr/bin/curl" + assert rule["action"] == "allow" + + def test_enable_after_disable(self, db, config): + db.node_seen("unix:/local", "h", "1.9.0") + db.replace_rules("unix:/local", [a_rule("r", enabled=False)]) + + assert run(["rule", "enable", "r"], config) == 0 + + [(ntf_type, rule)] = queued(db) + assert ntf_type == ui_pb2.ENABLE_RULE + assert rule["enabled"] is True + + def test_enabling_an_enabled_rule_sends_nothing(self, db, config, capsys): + db.node_seen("unix:/local", "h", "1.9.0") + db.replace_rules("unix:/local", [a_rule("r")]) + + assert run(["rule", "enable", "r"], config) == 0 + assert queued(db) == [] + assert "already enabled" in capsys.readouterr().out + + def test_enable_needs_a_rule_it_knows(self, db, config, capsys): + db.node_seen("unix:/local", "h", "1.9.0") + + assert run(["rule", "enable", "nope"], config) == 1 + assert queued(db) == [] + assert "no rule called 'nope'" in capsys.readouterr().err + + def test_delete_of_an_unknown_rule_is_sent_with_a_note(self, db, config, capsys): + """the list can be stale; deleting a rule the daemon lacks is harmless.""" + db.node_seen("unix:/local", "h", "1.9.0") + + assert run(["rule", "delete", "nope"], config) == 0 + assert len(queued(db)) == 1 + assert "not among the rules" in capsys.readouterr().err + + def test_with_several_nodes_the_node_must_be_named(self, db, config, capsys): + db.node_seen("unix:/local", "h", "1.9.0") + db.node_seen("ipv4:10.0.0.2", "h2", "1.9.0") + db.replace_rules("ipv4:10.0.0.2", [a_rule("r")]) + + assert run(["rule", "delete", "r"], config) == 1 + assert "--node" in capsys.readouterr().err + + assert run(["rule", "delete", "r", "--node", "ipv4:10.0.0.2"], config) == 0 + [row] = db.queued_notifications() + assert row["node"] == "ipv4:10.0.0.2" + + def test_an_unknown_node_is_refused(self, db, config, capsys): + db.node_seen("unix:/local", "h", "1.9.0") + assert run(["rule", "delete", "r", "--node", "ipv4:9.9.9.9"], config) == 1 + assert "no node called" in capsys.readouterr().err + + def test_nothing_to_send_to_without_a_node(self, db, config, capsys): + assert run(["rule", "delete", "r"], config) == 1 + assert "no node has connected" in capsys.readouterr().err + diff --git a/ui/tests/cli/test_db.py b/ui/tests/cli/test_db.py index c0fd1f4c5c..5b14a817ba 100644 --- a/ui/tests/cli/test_db.py +++ b/ui/tests/cli/test_db.py @@ -2,6 +2,8 @@ # pytest -v cli/test_db.py # +import json + from opensnitch.cli import db as dbmod @@ -132,6 +134,77 @@ def test_names_handed_out_earlier_are_taken(self, db): assert "allow-always-list-usr-bin-curl" not in db.rule_names("other") +def a_rule(name, enabled=True): + from opensnitch.cli.proto import ui_pb2 + + rule = ui_pb2.Rule(name=name, enabled=enabled, action="allow", duration="always") + rule.operator.type = "simple" + rule.operator.operand = "process.path" + rule.operator.data = "/usr/bin/curl" + return rule + + +class TestRules: + + def test_the_whole_rule_is_kept(self, db): + db.replace_rules("n", [a_rule("r")]) + row = db.get_rule("n", "r") + assert row["op_data"] == "/usr/bin/curl" + assert json.loads(row["rule_json"])["operator"]["data"] == "/usr/bin/curl" + + def test_a_confirmed_change_updates_the_list(self, db): + """the daemon only reports its rules on connecting; what it confirmed + since is the best knowledge there is.""" + from google.protobuf import json_format + from opensnitch.cli.proto import ui_pb2 + + db.replace_rules("n", [a_rule("r")]) + outbox_id = db.queue_notification( + "n", ui_pb2.CHANGE_RULE, json_format.MessageToJson(a_rule("r", enabled=False))) + db.mark_sent(outbox_id, 700) + db.mark_result(700, True) + assert db.get_rule("n", "r")["enabled"] == 0 + + outbox_id = db.queue_notification( + "n", ui_pb2.DELETE_RULE, json_format.MessageToJson(a_rule("r"))) + db.mark_sent(outbox_id, 701) + db.mark_result(701, True) + assert db.get_rule("n", "r") is None + + def test_a_rejected_change_leaves_the_list_alone(self, db): + from google.protobuf import json_format + from opensnitch.cli.proto import ui_pb2 + + db.replace_rules("n", [a_rule("r")]) + outbox_id = db.queue_notification( + "n", ui_pb2.CHANGE_RULE, json_format.MessageToJson(a_rule("r", enabled=False))) + db.mark_sent(outbox_id, 702) + db.mark_result(702, False, "no") + assert db.get_rule("n", "r")["enabled"] == 1 + + +class TestSchema: + + def test_a_schema_1_database_is_upgraded(self, tmp_path): + """the rules table gained a column; a queue from before must still open.""" + import sqlite3 + + path = str(tmp_path / "old.db") + old = sqlite3.connect(path) + old.executescript(dbmod.SCHEMA.replace(" rule_json TEXT,\n", "")) + old.execute("INSERT INTO rules (node, name, enabled) VALUES ('n', 'r', 1)") + old.execute("PRAGMA user_version=1") + old.commit() + old.close() + + db = dbmod.Database(path) + try: + row = db.get_rule("n", "r") + assert row["rule_json"] is None + assert db._db.execute("PRAGMA user_version").fetchone()[0] == dbmod.SCHEMA_VERSION + finally: + db.close() + class TestConcurrentAccess: From 25435d3f556819ce84dd9f726cac9cb2d9ddaf76 Mon Sep 17 00:00:00 2001 From: Consty Date: Fri, 14 Aug 2026 22:04:46 -0700 Subject: [PATCH 20/24] cli: agree on what online means, show the daemon version, warn on an old one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smaller things that came out of the same review. `nodes` and `status` reported the raw online flag, which a serve process that dies never clears; `review` already refused to believe it unless the node had been heard from in the last 90 seconds. They now share that test, and `nodes --json` reports the corrected value. `nodes` shows each daemon's version, and `serve` warns on Subscribe when it is older than 1.6.0. The notification types were renumbered in that release, and an older daemon does not refuse what this client sends — it misreads it, so every decision would be thrown away while status reported it delivered. Ubuntu 24.04 ships 1.5.8. Any other mismatch is mentioned once, quietly. A name typed by hand — `allow --name`, or 4 in the editor — is refused if a rule of that name exists on the node, because the daemon would replace it without a word; generated names already avoid this. Timestamps in `nodes` are printed as local time instead of the raw epoch, through the same helper the review screen uses. The systemd unit gets LogsDirectory=opensnitch, the only place its ProtectSystem=strict lets log.file be written; cli.conf.example says so. Co-Authored-By: Claude Fable 5 --- ui/opensnitch/cli/README.md | 22 +++++++++- ui/opensnitch/cli/durations.py | 10 ++++- ui/opensnitch/cli/main.py | 49 +++++++++++++++------ ui/opensnitch/cli/review.py | 25 +++++++---- ui/opensnitch/cli/service.py | 36 +++++++++++++++- ui/resources/cli.conf.example | 4 +- ui/resources/init/opensnitch-cli.service | 3 ++ ui/tests/cli/test_commands.py | 55 +++++++++++++++++++++++- ui/tests/cli/test_review.py | 18 ++++++++ ui/tests/cli/test_service.py | 24 +++++++++++ 10 files changed, 218 insertions(+), 28 deletions(-) diff --git a/ui/opensnitch/cli/README.md b/ui/opensnitch/cli/README.md index db9d11af95..059c21023a 100644 --- a/ui/opensnitch/cli/README.md +++ b/ui/opensnitch/cli/README.md @@ -132,6 +132,15 @@ You should see it listening, and then the daemon's log sudo opensnitch-cli nodes ``` +``` +ADDRESS HOSTNAME DAEMON ONLINE LAST SEEN +unix:/local server1 1.9.0 yes 2026-08-14 20:17:05 +``` + +`ONLINE` means `serve` has heard from the daemon in the last minute and a half, +not merely that it connected once. `DAEMON` must be 1.6.0 or later, see +"When something is wrong". + **2. Make a connection that has no rule yet.** ```bash @@ -283,7 +292,7 @@ because the daemon lowercases it and it would never match. | `drop ID` | remove from the queue without creating a rule | | `rules [--node N] [--json]` | the rules each daemon has | | `rule delete\|enable\|disable NAME [--node N]` | change a rule the daemon has | -| `nodes [--json]` | daemons that have connected | +| `nodes [--json]` | daemons that have connected, with their version | | `status [--json] [--retry\|--clear]` | queue depth, nodes, and rules the daemon rejected | `status` exits non-zero if any rule was rejected, so it works as a monitoring @@ -320,6 +329,10 @@ runs. It does not see rules that expire on the daemon on their own, or that were edited on disk by hand, until the daemon reconnects. With more than one node, `--node` says which daemon is meant; with one, it is implied. +A name given by hand — `allow ... --name`, or `4` in the editor — is refused if +a rule of that name exists, because the daemon would replace it without a word. +Delete the old one first if that is what you mean. + ## Configuration `/etc/opensnitch/cli.conf`, or `~/.config/opensnitch/cli.conf`. Running with no @@ -364,6 +377,13 @@ daemon is looking at a different `/tmp`. **`could not listen on ...`** — something already owns that socket, almost always `opensnitch-ui`. Only one client per daemon. +**`daemon version X is older than 1.6.0`** in the `serve` log. The notification +types were renumbered in daemon 1.6.0, and an older daemon does not refuse what +this client sends — it misreads it, so every decision would be thrown away +while `status` reports it delivered. Distribution packages can be that old +(Ubuntu 24.04 ships 1.5.8). Upgrade the daemon; `nodes` shows each daemon's +version. + **`Permission denied` opening the database.** The queue decides what the machine may connect to, so it is root-only. Use `sudo`. diff --git a/ui/opensnitch/cli/durations.py b/ui/opensnitch/cli/durations.py index f4634821d3..4c75f45fe3 100644 --- a/ui/opensnitch/cli/durations.py +++ b/ui/opensnitch/cli/durations.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License # along with OpenSnitch. If not, see . -"""Rule durations, as the daemon understands them. +"""Rule durations as the daemon understands them, and times for people to read. Anything that is not one of the three keywords is parsed by the daemon with Go's time.ParseDuration (daemon/rule/loader.go, scheduleTemporaryRule), which @@ -28,6 +28,7 @@ """ import re +import time from opensnitch.rule_consts import RuleConsts @@ -55,6 +56,13 @@ _GO_PART = re.compile(r'([0-9]+(?:\.[0-9]+)?)(ns|us|µs|ms|s|m|h)') +def format_time(timestamp): + """a stored epoch as local time, for the tables and the review loop.""" + if not timestamp: + return "?" + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) + + def validate(duration): """returns an error string, or None when the daemon will understand it.""" if duration in KEYWORDS: diff --git a/ui/opensnitch/cli/main.py b/ui/opensnitch/cli/main.py index a2315bcc29..457809159a 100644 --- a/ui/opensnitch/cli/main.py +++ b/ui/opensnitch/cli/main.py @@ -29,6 +29,7 @@ from opensnitch.version import version from opensnitch.rule_consts import RuleConsts +from opensnitch.cli import durations from opensnitch.cli.config import Config, ConfigError LOG_FORMAT = '%(asctime)s - [%(levelname)s][%(filename)s:%(lineno)d] %(message)s' @@ -110,7 +111,7 @@ def cmd_review(args, config): applied = 0 if applied: - if len(_served_nodes(db)) > 0: + if len(served_nodes(db)) > 0: print("\n%d rule(s) queued. The service applies them within a second; " "run 'opensnitch-cli status' to check." % applied) else: @@ -120,17 +121,26 @@ def cmd_review(args, config): return 0 -def _served_nodes(db, max_age=90): - """the nodes the serve service is talking to right now. +# While a node is connected its last_seen is refreshed every 30 seconds +# (server.py LAST_SEEN_INTERVAL); older than a few of those means nobody is +# serving it, whatever its online flag says. +SERVED_MAX_AGE = 90 + + +def is_served(node, now=None): + """whether the serve service is talking to this node right now. The online flag alone can lie: a serve process that dies never marks its - nodes offline. While a node is connected its last_seen is refreshed every - 30 seconds (server.py LAST_SEEN_INTERVAL), so anything older than a couple - of those is not actually being served. + nodes offline, so it is only believed while last_seen is fresh. """ + now = time.time() if now is None else now + return bool(node["online"] and node["last_seen"] + and now - node["last_seen"] < SERVED_MAX_AGE) + + +def served_nodes(db): now = time.time() - return [n for n in db.nodes() - if n["online"] and n["last_seen"] and now - n["last_seen"] < max_age] + return [n for n in db.nodes() if is_served(n, now)] class CommandError(Exception): @@ -181,6 +191,11 @@ def cmd_decide(args, config): return 1 decision.selected = matched[0] if args.name is not None: + if decision.name_is_taken(args.name): + print("opensnitch-cli: a rule named '%s' already exists on this node and would " + "be replaced. Pick another name, or delete it first with " + "'opensnitch-cli rule delete %s'" % (args.name, args.name), file=sys.stderr) + return 1 decision.name = args.name error = decision.validate() @@ -301,16 +316,24 @@ def cmd_rule(args, config): def cmd_nodes(args, config): db = open_db(config) nodes = db.nodes() + now = time.time() if args.json: - print(json.dumps([dict(n) for n in nodes], indent=2)) + out = [] + for n in nodes: + row = dict(n) + row["online"] = is_served(n, now) + out.append(row) + print(json.dumps(out, indent=2)) return 0 if len(nodes) == 0: print("no node has connected yet") return 0 - print("%-28s %-20s %-8s %s" % ("ADDRESS", "HOSTNAME", "ONLINE", "LAST SEEN")) + print("%-24s %-18s %-9s %-7s %s" % ("ADDRESS", "HOSTNAME", "DAEMON", "ONLINE", "LAST SEEN")) for node in nodes: - print("%-28s %-20s %-8s %s" % (node["addr"], node["hostname"] or "?", - "yes" if node["online"] else "no", node["last_seen"])) + print("%-24s %-18s %-9s %-7s %s" % (node["addr"], node["hostname"] or "?", + node["version"] or "?", + "yes" if is_served(node, now) else "no", + durations.format_time(node["last_seen"]))) return 0 @@ -324,7 +347,7 @@ def cmd_status(args, config): "the review queue" % db.clear_errors()) nodes = db.nodes() - online = len([n for n in nodes if n["online"]]) + online = len(served_nodes(db)) errors = db.outbox_errors() queued = db.queued_count() diff --git a/ui/opensnitch/cli/review.py b/ui/opensnitch/cli/review.py index 957e4ad733..ed0cbf56c7 100644 --- a/ui/opensnitch/cli/review.py +++ b/ui/opensnitch/cli/review.py @@ -43,12 +43,6 @@ """ -def _fmt_age(timestamp): - if not timestamp: - return "?" - return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) - - def _args_of(entry): try: return json.loads(entry["process_args"] or "[]") @@ -150,6 +144,14 @@ def name(self): def name(self, value): self._custom_name = value + def name_is_taken(self, value): + """whether a name the user typed would replace a rule that exists. + + Generated names avoid this by themselves; a typed one has to be + checked, because the daemon replaces rules by name without a word. + """ + return value in self._taken + def build(self): ops = self.operators() if len(ops) == 0: @@ -181,7 +183,8 @@ def render_entry(entry, con, index, total, write): write(SEPARATOR) write("[%d/%d] %s seen %sx first %s last %s" % ( index, total, entry["node"], entry["hits"], - _fmt_age(entry["first_seen"]), _fmt_age(entry["last_seen"]))) + durations.format_time(entry["first_seen"]), + durations.format_time(entry["last_seen"]))) process = entry["process_path"] or "(unknown process)" write(" %s pid %s uid %s" % (process, entry["process_id"], entry["user_id"])) @@ -274,7 +277,13 @@ def edit_menu(decision, read, write): _choose_duration(decision, read, write) elif choice == "4": value = read(" rule name: ").strip() - if value != "": + if value == "": + pass + elif decision.name_is_taken(value): + write(" a rule named '%s' already exists on this node and would be " + "replaced; pick another name, or delete it first with " + "'opensnitch-cli rule delete %s'" % (value, value)) + else: decision.name = value elif choice == "5": _choose_extra(decision, read, write) diff --git a/ui/opensnitch/cli/service.py b/ui/opensnitch/cli/service.py index c74f9c559e..100426752d 100644 --- a/ui/opensnitch/cli/service.py +++ b/ui/opensnitch/cli/service.py @@ -29,6 +29,9 @@ import threading import time +from packaging.version import Version, InvalidVersion + +from opensnitch.version import version as client_version from opensnitch.cli.proto import ui_pb2, ui_pb2_grpc logger = logging.getLogger(__name__) @@ -37,6 +40,32 @@ # stream for any notification type <= NONE (daemon/ui/notifications.go). CLOSE_STREAM = -1 +# The notification types were renumbered in 1.6.0 (proto/ui.proto enum Action). +# An older daemon does not reject what we send, it misreads it: our CHANGE_RULE +# is its MONITOR_PROCESS and our DELETE_RULE is its STOP, so every decision +# would be silently thrown away while we record it as delivered. +MIN_DAEMON_VERSION = "1.6.0" + + +def version_warning(daemon_version): + """what to tell the operator about a daemon's version, or None.""" + try: + theirs = Version(daemon_version) + except InvalidVersion: + return None + if theirs < Version(MIN_DAEMON_VERSION): + return ("daemon version {0} is older than {1}: it will misread the rules this " + "client sends and none of your decisions will take effect. Upgrade the " + "daemon.".format(daemon_version, MIN_DAEMON_VERSION)) + try: + if theirs != Version(client_version): + return ("daemon version {0} differs from this client's {1}; the two are " + "expected to interoperate, but keep them in step if something " + "looks off".format(daemon_version, client_version)) + except InvalidVersion: + pass + return None + class Node: def __init__(self, addr, peer): @@ -155,8 +184,11 @@ def Subscribe(self, node_config, context): self._db.node_seen(addr, node_config.name, node_config.version, online=True) self._db.replace_rules(addr, node_config.rules) - logger.info("node connected: %s (%s), %d rules", addr, node_config.name, - len(node_config.rules)) + logger.info("node connected: %s (%s, daemon %s), %d rules", addr, node_config.name, + node_config.version, len(node_config.rules)) + warning = version_warning(node_config.version) + if warning is not None: + logger.warning("%s: %s", addr, warning) return self._with_default_action(node_config) def _with_default_action(self, node_config): diff --git a/ui/resources/cli.conf.example b/ui/resources/cli.conf.example index 9f8760d432..eb43db7bcb 100644 --- a/ui/resources/cli.conf.example +++ b/ui/resources/cli.conf.example @@ -74,6 +74,8 @@ retention_days = 30 [log] level = info -# empty logs to stderr, which is what the systemd unit wants +# empty logs to stderr, which is what the systemd unit wants. Under the unit +# the file system is read-only apart from a few directories; a log file has +# to live under /var/log/opensnitch (LogsDirectory= in the unit). file = store_alerts = false diff --git a/ui/resources/init/opensnitch-cli.service b/ui/resources/init/opensnitch-cli.service index f294013367..104c4ca6ea 100644 --- a/ui/resources/init/opensnitch-cli.service +++ b/ui/resources/init/opensnitch-cli.service @@ -12,6 +12,9 @@ RestartSec=10 # /var/lib/opensnitch, 0700. The queue decides what this machine is allowed to # connect to, so it must not be readable by everyone. StateDirectory=opensnitch +# /var/log/opensnitch, the only place ProtectSystem=strict below lets a +# log.file in cli.conf be written +LogsDirectory=opensnitch # The default socket is /tmp/osui.sock, and a private /tmp would hide it from # the daemon. Point server.address at /run/opensnitch/cli.sock in cli.conf, diff --git a/ui/tests/cli/test_commands.py b/ui/tests/cli/test_commands.py index db05c621c6..8334752ae2 100644 --- a/ui/tests/cli/test_commands.py +++ b/ui/tests/cli/test_commands.py @@ -1,8 +1,9 @@ # # pytest -v cli/test_commands.py # -# The commands of opensnitch-cli that act on the database without a daemon, -# driven the way main() drives them: through the parser and the cmd_* function. +# The commands of opensnitch-cli that act on the database without a daemon: +# rule delete/enable/disable, nodes, status. Each is driven the way main() +# drives it, through the parser and the cmd_* function. # import json @@ -117,3 +118,53 @@ def test_nothing_to_send_to_without_a_node(self, db, config, capsys): assert run(["rule", "delete", "r"], config) == 1 assert "no node has connected" in capsys.readouterr().err + +class TestNodes: + + def test_online_means_heard_from_recently(self, db, config, capsys): + """a serve process that dies never marks its nodes offline, so the flag + alone is not believed.""" + db.node_seen("unix:/local", "fresh", "1.9.0", online=True) + db.node_seen("ipv4:10.0.0.2", "stale", "1.9.0", online=True) + db._db.execute("UPDATE nodes SET last_seen=? WHERE addr='ipv4:10.0.0.2'", + (int(time.time()) - 600,)) + + assert run(["nodes", "--json"], config) == 0 + rows = {n["addr"]: n for n in json.loads(capsys.readouterr().out)} + assert rows["unix:/local"]["online"] is True + assert rows["ipv4:10.0.0.2"]["online"] is False + + def test_the_daemon_version_is_shown(self, db, config, capsys): + db.node_seen("unix:/local", "h", "1.9.0") + assert run(["nodes"], config) == 0 + out = capsys.readouterr().out + assert "DAEMON" in out + assert "1.9.0" in out + assert "1786" not in out, "last seen should be a date, not an epoch" + + +class TestStatus: + + def test_nodes_online_agrees_with_nodes(self, db, config, capsys): + db.node_seen("unix:/local", "stale", "1.9.0", online=True) + db._db.execute("UPDATE nodes SET last_seen=?", (int(time.time()) - 600,)) + + assert run(["status", "--json"], config) == 0 + status = json.loads(capsys.readouterr().out) + assert status["nodes"] == 1 + assert status["nodes_online"] == 0 + + +class TestDecideName: + + def test_a_name_in_use_is_refused(self, db, config, connection, capsys): + """the daemon replaces rules by name without a word.""" + db.node_seen("unix:/local", "h", "1.9.0") + db.replace_rules("unix:/local", [a_rule("mine")]) + entry_id, _ = db.record_pending("unix:/local", "sig", connection, {}) + + assert run(["allow", str(entry_id), "--name", "mine"], config) == 1 + assert "already exists" in capsys.readouterr().err + assert db.get_pending(entry_id)["state"] == dbmod.STATE_PENDING + + assert run(["allow", str(entry_id), "--name", "mine-2"], config) == 0 diff --git a/ui/tests/cli/test_review.py b/ui/tests/cli/test_review.py index 56b7cebcd6..cc388bd354 100644 --- a/ui/tests/cli/test_review.py +++ b/ui/tests/cli/test_review.py @@ -306,6 +306,24 @@ def test_cancelling_the_editor_goes_back(self, db, config, connection): assert applied == 0 assert db.pending_count() == 1 + def test_a_typed_name_that_exists_is_refused(self, db, config, connection): + """the daemon would replace the existing rule without a word.""" + taken = ui_pb2.Rule(name="mine", enabled=True, action="allow", duration="always") + taken.operator.type = "simple" + taken.operator.operand = "process.path" + taken.operator.data = "/usr/bin/wget" + db.replace_rules("unix:/local", [taken]) + queue_one(db, connection) + + told = [] + # e -> name -> "mine" (refused) -> name -> "mine-2" -> apply + answers = ["e", "4", "mine", "4", "mine-2", "a"] + review.review_loop(db, db.pending(), config, read=scripted(answers), write=told.append) + + names = [rule["name"] for t, rule in sent_rules(db) if t == ui_pb2.CHANGE_RULE] + assert names == ["mine-2"] + assert any("already exists" in line for line in told) + def test_no_duplicate_when_the_match_becomes_a_condition(self, db, config, connection): """add a condition, then switch the match to that same candidate. diff --git a/ui/tests/cli/test_service.py b/ui/tests/cli/test_service.py index c3bb62e810..a9c20f81eb 100644 --- a/ui/tests/cli/test_service.py +++ b/ui/tests/cli/test_service.py @@ -70,6 +70,30 @@ def test_ipv6(self, db, config): assert service.peer_addr("ipv6:[fe80::1%eth0]:1") == "ipv6:[fe80::1%eth0]" +class TestVersionWarning: + """an old daemon does not reject what we send, it misreads it.""" + + def test_a_daemon_from_before_the_renumbering_is_flagged(self): + from opensnitch.cli.service import version_warning + assert "older than 1.6.0" in version_warning("1.5.8") + assert "older than 1.6.0" in version_warning("1.5.8.1") + + def test_a_different_but_compatible_version_is_only_mentioned(self): + from opensnitch.cli.service import version_warning + warning = version_warning("1.6.5") + assert warning is not None + assert "older than" not in warning + + def test_the_same_version_says_nothing(self): + from opensnitch.cli.service import version_warning + from opensnitch.version import version + assert version_warning(version) is None + + def test_garbage_says_nothing(self): + from opensnitch.cli.service import version_warning + assert version_warning("") is None + assert version_warning("git-abc123") is None + class TestSubscribe: From 4622e71a6406f81f5e3241e88e6e35b539cee08e Mon Sep 17 00:00:00 2001 From: Consty Date: Fri, 14 Aug 2026 22:25:28 -0700 Subject: [PATCH 21/24] cli: accept --config, --db and --log-level after the subcommand too 'opensnitch-cli serve --log-level debug' is what people type, and what the README shows, but the options were defined on the main parser only, so argparse refused them after the subcommand. Every subcommand now takes them as well, with SUPPRESS as their default there so that a value given before the subcommand is not overwritten by the subparser's default. Co-Authored-By: Claude Fable 5 --- ui/opensnitch/cli/main.py | 43 +++++++++++++++++++++++++---------- ui/tests/cli/test_commands.py | 17 ++++++++++++++ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/ui/opensnitch/cli/main.py b/ui/opensnitch/cli/main.py index 457809159a..9d868aeaa8 100644 --- a/ui/opensnitch/cli/main.py +++ b/ui/opensnitch/cli/main.py @@ -375,60 +375,79 @@ def cmd_status(args, config): return 1 if len(errors) > 0 else 0 +def _add_common_options(parser, default): + """--config, --db and --log-level. + + Added to the main parser and to every subcommand, so that both + 'opensnitch-cli --log-level debug serve' and 'opensnitch-cli serve + --log-level debug' work. On the subcommands the default is SUPPRESS: a + subparser's default would otherwise overwrite a value given before the + subcommand. + """ + parser.add_argument("--config", default=default, help="path to cli.conf") + parser.add_argument("--db", default=default, + help="path to the queue database, overrides the config file") + parser.add_argument("--log-level", default=default, + choices=("debug", "info", "warning", "error")) + + def build_parser(): parser = argparse.ArgumentParser( prog="opensnitch-cli", description="Review and answer OpenSnitch connection prompts from a terminal.") parser.add_argument("--version", action="version", version="opensnitch-cli %s" % version) - parser.add_argument("--config", help="path to cli.conf") - parser.add_argument("--db", help="path to the queue database, overrides the config file") - parser.add_argument("--log-level", choices=("debug", "info", "warning", "error")) + _add_common_options(parser, default=None) subparsers = parser.add_subparsers(dest="command") - serve = subparsers.add_parser("serve", help="answer the daemon and record connections") + def add_command(name, **kwargs): + sub = subparsers.add_parser(name, **kwargs) + _add_common_options(sub, default=argparse.SUPPRESS) + return sub + + serve = add_command("serve", help="answer the daemon and record connections") serve.add_argument("--socket", help="address to listen on, overrides the config file") serve.set_defaults(func=cmd_serve) - pending = subparsers.add_parser("pending", help="list connections waiting to be reviewed") + pending = add_command("pending", help="list connections waiting to be reviewed") pending.add_argument("--node") pending.add_argument("--limit", type=int) pending.add_argument("--json", action="store_true") pending.set_defaults(func=cmd_pending) - review_cmd = subparsers.add_parser("review", help="go through the queue one by one") + review_cmd = add_command("review", help="go through the queue one by one") review_cmd.add_argument("--node") review_cmd.add_argument("--limit", type=int) review_cmd.set_defaults(func=cmd_review) for action in (RuleConsts.ACTION_ALLOW, RuleConsts.ACTION_DENY, RuleConsts.ACTION_REJECT): - decide = subparsers.add_parser(action, help="%s a queue entry without prompting" % action) + decide = add_command(action, help="%s a queue entry without prompting" % action) decide.add_argument("id", type=int) decide.add_argument("--match", help="operand to match on, for example dest.host") decide.add_argument("--duration", default=RuleConsts.DURATION_ALWAYS) decide.add_argument("--name") decide.set_defaults(func=cmd_decide, action=action) - drop = subparsers.add_parser("drop", help="remove a queue entry without creating a rule") + drop = add_command("drop", help="remove a queue entry without creating a rule") drop.add_argument("id", type=int) drop.set_defaults(func=cmd_drop) - rules_cmd = subparsers.add_parser("rules", help="the rules each daemon has") + rules_cmd = add_command("rules", help="the rules each daemon has") rules_cmd.add_argument("--node") rules_cmd.add_argument("--json", action="store_true") rules_cmd.set_defaults(func=cmd_rules) - rule_cmd = subparsers.add_parser("rule", help="delete, enable or disable a rule by name") + rule_cmd = add_command("rule", help="delete, enable or disable a rule by name") rule_cmd.add_argument("verb", choices=("delete", "enable", "disable")) rule_cmd.add_argument("name") rule_cmd.add_argument("--node", help="which daemon, when more than one has connected") rule_cmd.set_defaults(func=cmd_rule) - nodes = subparsers.add_parser("nodes", help="daemons that have connected") + nodes = add_command("nodes", help="daemons that have connected") nodes.add_argument("--json", action="store_true") nodes.set_defaults(func=cmd_nodes) - status = subparsers.add_parser("status", help="queue depth, nodes and failed rules") + status = add_command("status", help="queue depth, nodes and failed rules") status.add_argument("--json", action="store_true") failed = status.add_mutually_exclusive_group() failed.add_argument("--retry", action="store_true", diff --git a/ui/tests/cli/test_commands.py b/ui/tests/cli/test_commands.py index 8334752ae2..a72eb596e9 100644 --- a/ui/tests/cli/test_commands.py +++ b/ui/tests/cli/test_commands.py @@ -33,6 +33,23 @@ def queued(db): for row in db.queued_notifications()] +class TestParser: + + def test_common_options_work_on_either_side_of_the_command(self): + """'serve --log-level debug' is what people type; it must not be an + error just because --log-level is defined on the main parser.""" + parser = main.build_parser() + assert parser.parse_args(["--log-level", "debug", "serve"]).log_level == "debug" + assert parser.parse_args(["serve", "--log-level", "debug"]).log_level == "debug" + + def test_a_value_given_before_the_command_survives(self): + """a subparser's default must not overwrite the main parser's value.""" + parser = main.build_parser() + args = parser.parse_args(["--db", "/tmp/x.db", "status", "--json"]) + assert args.db == "/tmp/x.db" + assert args.log_level is None + + class TestRule: def test_delete_queues_a_delete_for_the_daemon(self, db, config, capsys): From c05c7137c915bc62eff569c97d643f986f664e78 Mon Sep 17 00:00:00 2001 From: Consty Date: Fri, 14 Aug 2026 22:33:45 -0700 Subject: [PATCH 22/24] cli: add undo, and pending --decided to find what to undo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A decision could not be taken back. The only way to reverse a deny was to know the name of the rule it created, delete that, and wait for the program to try again so the connection was queued once more. `pending --decided` lists what was decided, with the rule each connection got. `undo ID` withdraws that rule and puts the connection back in the queue as if it had never been answered — and every other connection the same rule settled comes back with it, since the rule that answered them is going away. Co-Authored-By: Claude Fable 5 --- ui/opensnitch/cli/README.md | 20 ++++++++- ui/opensnitch/cli/db.py | 27 ++++++++++++ ui/opensnitch/cli/main.py | 79 ++++++++++++++++++++++++++++++++++- ui/tests/cli/test_commands.py | 55 ++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 3 deletions(-) diff --git a/ui/opensnitch/cli/README.md b/ui/opensnitch/cli/README.md index 059c21023a..dac2644c5a 100644 --- a/ui/opensnitch/cli/README.md +++ b/ui/opensnitch/cli/README.md @@ -286,10 +286,11 @@ because the daemon lowercases it and it would never match. | --- | --- | | `serve` | answer the daemon and record connections | | `review` | go through the queue one at a time | -| `pending [--json] [--node N] [--limit N]` | list what is waiting | +| `pending [--json] [--node N] [--limit N] [--decided]` | list what is waiting, or what was decided | | `allow ID [--match OPERAND] [--duration D] [--name N]` | approve without prompting | | `deny ID` / `reject ID` | refuse without prompting | | `drop ID` | remove from the queue without creating a rule | +| `undo ID` | take a decision back: withdraw its rule, re-queue the connection | | `rules [--node N] [--json]` | the rules each daemon has | | `rule delete\|enable\|disable NAME [--node N]` | change a rule the daemon has | | `nodes [--json]` | daemons that have connected, with their version | @@ -329,6 +330,23 @@ runs. It does not see rules that expire on the daemon on their own, or that were edited on disk by hand, until the daemon reconnects. With more than one node, `--node` says which daemon is meant; with one, it is implied. +## Changing your mind + +A decision is a rule, so taking it back means withdrawing the rule. +`pending --decided` lists what was decided, with the rule each connection +got, and `undo ID` withdraws that rule and puts the connection back in the +queue, exactly as if it had never been answered: + +```bash +sudo opensnitch-cli pending --decided +sudo opensnitch-cli undo 7 +sudo opensnitch-cli review # it is back, decide again +``` + +If one approval settled several queued connections (see "Approve it" above), +undoing any of them brings all of them back, since the one rule that answered +them is going away. + A name given by hand — `allow ... --name`, or `4` in the editor — is refused if a rule of that name exists, because the daemon would replace it without a word. Delete the old one first if that is what you mean. diff --git a/ui/opensnitch/cli/db.py b/ui/opensnitch/cli/db.py index b5c1cd1bac..2eed189901 100644 --- a/ui/opensnitch/cli/db.py +++ b/ui/opensnitch/cli/db.py @@ -280,6 +280,33 @@ def set_pending_state(self, entry_id, state, rule_json=None): "UPDATE pending SET state=?, decided_at=?, decided_rule=? WHERE id=?", (state, int(time.time()), rule_json, entry_id)) + def decided_by(self, node, rule_name): + """the decided entries a rule of that name settled on a node. + + One approval can settle several queued connections (review.py + resolve_covered), and they all record the same rule. + """ + with self._lock: + rows = self._db.execute( + "SELECT * FROM pending WHERE node=? AND state=? AND decided_rule IS NOT NULL", + (node, STATE_DECIDED)).fetchall() + found = [] + for row in rows: + try: + if json.loads(row["decided_rule"]).get("name") == rule_name: + found.append(row) + except (ValueError, AttributeError): + continue + return found + + def reopen(self, entry_ids): + """puts decided entries back in the queue, as if never answered.""" + with self._lock: + for entry_id in entry_ids: + self._db.execute( + "UPDATE pending SET state=?, decided_at=NULL, decided_rule=NULL WHERE id=?", + (STATE_PENDING, entry_id)) + def expire_provisionals(self): """clears the provisional rule of entries whose temporary rule has gone. diff --git a/ui/opensnitch/cli/main.py b/ui/opensnitch/cli/main.py index 9d868aeaa8..4b96320bcf 100644 --- a/ui/opensnitch/cli/main.py +++ b/ui/opensnitch/cli/main.py @@ -77,16 +77,40 @@ def _entry_summary(entry): "%sx" % entry["hits"]) +def _decision_summary(entry): + """what was decided for an entry: 'allow always as NAME'.""" + try: + rule = json.loads(entry["decided_rule"] or "{}") + except ValueError: + rule = {} + if not rule: + return "?" + return "%s %s as %s" % (rule.get("action", "?"), rule.get("duration", "?"), + rule.get("name", "?")) + + def cmd_pending(args, config): + from opensnitch.cli import db as dbmod + db = open_db(config) - entries = db.pending(node=args.node, limit=args.limit) + state = dbmod.STATE_DECIDED if args.decided else dbmod.STATE_PENDING + entries = db.pending(node=args.node, limit=args.limit, state=state) if args.json: print(json.dumps([dict(e) for e in entries], indent=2)) return 0 if len(entries) == 0: - print("nothing waiting to be reviewed") + print("nothing has been decided yet" if args.decided else "nothing waiting to be reviewed") + return 0 + + if args.decided: + print("%-5s %-28s %-38s %s" % ("ID", "PROCESS", "DESTINATION", "DECISION")) + for entry in entries: + destination = entry["dst_host"] or entry["dst_ip"] or "?" + print("%-5s %-28s %-38s %s" % ( + entry["id"], (entry["process_path"] or "?")[-28:], + "%s:%s" % (destination, entry["dst_port"]), _decision_summary(entry))) return 0 print("%-5s %-28s %-38s %s" % ("ID", "PROCESS", "DESTINATION", "SEEN")) @@ -95,6 +119,50 @@ def cmd_pending(args, config): return 0 +def cmd_undo(args, config): + """takes back a decision: withdraws its rule, reopens the queue entry.""" + from google.protobuf import json_format + from opensnitch.cli.proto import ui_pb2 + from opensnitch.cli import db as dbmod + + db = open_db(config) + entry = db.get_pending(args.id) + if entry is None: + print("opensnitch-cli: no queue entry with id %s" % args.id, file=sys.stderr) + return 1 + if entry["state"] != dbmod.STATE_DECIDED: + print("opensnitch-cli: entry %s is %s, there is no decision to undo" % ( + args.id, entry["state"]), file=sys.stderr) + return 1 + + try: + rule_name = json.loads(entry["decided_rule"] or "{}").get("name") + except ValueError: + rule_name = None + if not rule_name: + print("opensnitch-cli: entry %s does not say which rule decided it" % args.id, + file=sys.stderr) + return 1 + + # the same rule may have settled other queued connections; they all come + # back, since the rule that answered them is going away + covered = db.decided_by(entry["node"], rule_name) + stale = ui_pb2.Rule(name=rule_name) + stale.operator.type = RuleConsts.RULE_TYPE_SIMPLE + stale.operator.operand = "true" + db.queue_notification(entry["node"], ui_pb2.DELETE_RULE, json_format.MessageToJson(stale), + pending_id=entry["id"]) + db.reopen([e["id"] for e in covered]) + + print("queued: delete rule '%s'; %d connection(s) back in the review queue" % ( + rule_name, len(covered))) + for other in covered: + destination = other["dst_host"] or other["dst_ip"] or "?" + print(" %s %s -> %s:%s" % (other["id"], other["process_path"] or "(unknown process)", + destination, other["dst_port"])) + return 0 + + def cmd_review(args, config): from opensnitch.cli import review @@ -413,8 +481,15 @@ def add_command(name, **kwargs): pending.add_argument("--node") pending.add_argument("--limit", type=int) pending.add_argument("--json", action="store_true") + pending.add_argument("--decided", action="store_true", + help="list what has been decided instead, with the rule each got") pending.set_defaults(func=cmd_pending) + undo = add_command("undo", help="take a decision back: withdraw its rule, re-queue " + "the connection") + undo.add_argument("id", type=int) + undo.set_defaults(func=cmd_undo) + review_cmd = add_command("review", help="go through the queue one by one") review_cmd.add_argument("--node") review_cmd.add_argument("--limit", type=int) diff --git a/ui/tests/cli/test_commands.py b/ui/tests/cli/test_commands.py index a72eb596e9..5ed5e12661 100644 --- a/ui/tests/cli/test_commands.py +++ b/ui/tests/cli/test_commands.py @@ -136,6 +136,61 @@ def test_nothing_to_send_to_without_a_node(self, db, config, capsys): assert "no node has connected" in capsys.readouterr().err +class TestUndo: + + def _decide(self, db, connection, node="unix:/local", sig="sig", rule_name="deny-always-simple-usr-bin-curl"): + entry_id, _ = db.record_pending(node, sig, connection, {}) + db.set_pending_state(entry_id, dbmod.STATE_DECIDED, + json.dumps({"name": rule_name, "action": "deny", + "duration": "always"})) + return entry_id + + def test_a_denied_connection_comes_back_and_its_rule_is_withdrawn(self, db, config, + connection, capsys): + entry_id = self._decide(db, connection) + + assert run(["undo", str(entry_id)], config) == 0 + + entry = db.get_pending(entry_id) + assert entry["state"] == dbmod.STATE_PENDING + assert entry["decided_rule"] is None + [(ntf_type, rule)] = queued(db) + assert ntf_type == ui_pb2.DELETE_RULE + assert rule["name"] == "deny-always-simple-usr-bin-curl" + assert "1 connection(s) back" in capsys.readouterr().out + + def test_every_connection_the_rule_settled_comes_back(self, db, config, connection, capsys): + """one approval can settle several entries; undoing it reopens them all, + since the rule that answered them is going away.""" + first = self._decide(db, connection, sig="a") + second = self._decide(db, connection, sig="b") + other_rule = self._decide(db, connection, sig="c", rule_name="allow-always-simple-x") + + assert run(["undo", str(first)], config) == 0 + + assert db.get_pending(first)["state"] == dbmod.STATE_PENDING + assert db.get_pending(second)["state"] == dbmod.STATE_PENDING + assert db.get_pending(other_rule)["state"] == dbmod.STATE_DECIDED + assert len(queued(db)) == 1, "one rule, one delete" + assert "2 connection(s) back" in capsys.readouterr().out + + def test_only_a_decided_entry_can_be_undone(self, db, config, connection, capsys): + entry_id, _ = db.record_pending("unix:/local", "sig", connection, {}) + assert run(["undo", str(entry_id)], config) == 1 + assert "no decision to undo" in capsys.readouterr().err + assert run(["undo", "999"], config) == 1 + + def test_pending_decided_lists_what_was_decided(self, db, config, connection, capsys): + entry_id = self._decide(db, connection) + assert run(["pending", "--decided"], config) == 0 + out = capsys.readouterr().out + assert "DECISION" in out + assert "deny always as deny-always-simple-usr-bin-curl" in out + + assert run(["pending"], config) == 0 + assert "nothing waiting" in capsys.readouterr().out + + class TestNodes: def test_online_means_heard_from_recently(self, db, config, capsys): From 953dbf0bba824f1edd7d2d5c0875f5e95e160a37 Mon Sep 17 00:00:00 2001 From: Consty Date: Fri, 14 Aug 2026 22:39:41 -0700 Subject: [PATCH 23/24] cli: let undo take a rule name as well as a queue id `rules` shows names and no ids, and that is where people look for the thing to reverse. `undo NAME` withdraws the rule and reopens every queued connection that recorded it; if none did, it says so and the daemon simply asks again the next time the program connects. The node is taken from those entries when they exist, otherwise --node has to say, as for `rule`. Co-Authored-By: Claude Fable 5 --- ui/opensnitch/cli/README.md | 8 ++-- ui/opensnitch/cli/db.py | 9 ++-- ui/opensnitch/cli/main.py | 79 ++++++++++++++++++++++------------- ui/tests/cli/test_commands.py | 31 ++++++++++++++ 4 files changed, 93 insertions(+), 34 deletions(-) diff --git a/ui/opensnitch/cli/README.md b/ui/opensnitch/cli/README.md index dac2644c5a..f2849baeeb 100644 --- a/ui/opensnitch/cli/README.md +++ b/ui/opensnitch/cli/README.md @@ -290,7 +290,7 @@ because the daemon lowercases it and it would never match. | `allow ID [--match OPERAND] [--duration D] [--name N]` | approve without prompting | | `deny ID` / `reject ID` | refuse without prompting | | `drop ID` | remove from the queue without creating a rule | -| `undo ID` | take a decision back: withdraw its rule, re-queue the connection | +| `undo ID\|NAME [--node N]` | take a decision back: withdraw its rule, re-queue the connection | | `rules [--node N] [--json]` | the rules each daemon has | | `rule delete\|enable\|disable NAME [--node N]` | change a rule the daemon has | | `nodes [--json]` | daemons that have connected, with their version | @@ -334,12 +334,14 @@ node, `--node` says which daemon is meant; with one, it is implied. A decision is a rule, so taking it back means withdrawing the rule. `pending --decided` lists what was decided, with the rule each connection -got, and `undo ID` withdraws that rule and puts the connection back in the -queue, exactly as if it had never been answered: +got, and `undo` withdraws that rule and puts the connection back in the +queue, exactly as if it had never been answered. It takes the queue id from +that list, or the rule's name from `rules` — whichever you are looking at: ```bash sudo opensnitch-cli pending --decided sudo opensnitch-cli undo 7 +sudo opensnitch-cli undo deny-always-simple-usr-bin-curl # the same thing, by name sudo opensnitch-cli review # it is back, decide again ``` diff --git a/ui/opensnitch/cli/db.py b/ui/opensnitch/cli/db.py index 2eed189901..e05c8ee0ef 100644 --- a/ui/opensnitch/cli/db.py +++ b/ui/opensnitch/cli/db.py @@ -286,10 +286,13 @@ def decided_by(self, node, rule_name): One approval can settle several queued connections (review.py resolve_covered), and they all record the same rule. """ + query = "SELECT * FROM pending WHERE state=? AND decided_rule IS NOT NULL" + args = [STATE_DECIDED] + if node is not None: + query += " AND node=?" + args.append(node) with self._lock: - rows = self._db.execute( - "SELECT * FROM pending WHERE node=? AND state=? AND decided_rule IS NOT NULL", - (node, STATE_DECIDED)).fetchall() + rows = self._db.execute(query, args).fetchall() found = [] for row in rows: try: diff --git a/ui/opensnitch/cli/main.py b/ui/opensnitch/cli/main.py index 4b96320bcf..42a96808fc 100644 --- a/ui/opensnitch/cli/main.py +++ b/ui/opensnitch/cli/main.py @@ -120,46 +120,67 @@ def cmd_pending(args, config): def cmd_undo(args, config): - """takes back a decision: withdraws its rule, reopens the queue entry.""" + """takes back a decision: withdraws its rule, reopens what it decided. + + Given a queue id (what pending --decided shows) or a rule name (what + rules shows): people look for the thing to reverse in both places. + """ from google.protobuf import json_format from opensnitch.cli.proto import ui_pb2 from opensnitch.cli import db as dbmod db = open_db(config) - entry = db.get_pending(args.id) - if entry is None: - print("opensnitch-cli: no queue entry with id %s" % args.id, file=sys.stderr) - return 1 - if entry["state"] != dbmod.STATE_DECIDED: - print("opensnitch-cli: entry %s is %s, there is no decision to undo" % ( - args.id, entry["state"]), file=sys.stderr) - return 1 - try: - rule_name = json.loads(entry["decided_rule"] or "{}").get("name") - except ValueError: - rule_name = None - if not rule_name: - print("opensnitch-cli: entry %s does not say which rule decided it" % args.id, - file=sys.stderr) - return 1 + entry = db.get_pending(int(args.what)) if args.what.isdigit() else None + if entry is not None: + if entry["state"] != dbmod.STATE_DECIDED: + print("opensnitch-cli: entry %s is %s, there is no decision to undo" % ( + args.what, entry["state"]), file=sys.stderr) + return 1 + try: + rule_name = json.loads(entry["decided_rule"] or "{}").get("name") + except ValueError: + rule_name = None + if not rule_name: + print("opensnitch-cli: entry %s does not say which rule decided it" % args.what, + file=sys.stderr) + return 1 + node = entry["node"] + else: + rule_name = args.what + # a rule name is only unique on one node; the entries it decided say + # which, otherwise --node has to + nodes = sorted(set(e["node"] for e in db.decided_by(None, rule_name))) + if len(nodes) == 1 and (args.node is None or args.node == nodes[0]): + node = nodes[0] + else: + try: + node = resolve_node(db, args.node) + except CommandError as e: + print("opensnitch-cli: %s" % e, file=sys.stderr) + return 1 - # the same rule may have settled other queued connections; they all come + # the same rule may have settled several queued connections; they all come # back, since the rule that answered them is going away - covered = db.decided_by(entry["node"], rule_name) + covered = db.decided_by(node, rule_name) stale = ui_pb2.Rule(name=rule_name) stale.operator.type = RuleConsts.RULE_TYPE_SIMPLE stale.operator.operand = "true" - db.queue_notification(entry["node"], ui_pb2.DELETE_RULE, json_format.MessageToJson(stale), - pending_id=entry["id"]) + db.queue_notification(node, ui_pb2.DELETE_RULE, json_format.MessageToJson(stale), + pending_id=covered[0]["id"] if covered else None) db.reopen([e["id"] for e in covered]) - print("queued: delete rule '%s'; %d connection(s) back in the review queue" % ( - rule_name, len(covered))) - for other in covered: - destination = other["dst_host"] or other["dst_ip"] or "?" - print(" %s %s -> %s:%s" % (other["id"], other["process_path"] or "(unknown process)", - destination, other["dst_port"])) + if covered: + print("queued: delete rule '%s' on %s; %d connection(s) back in the review queue" % ( + rule_name, node, len(covered))) + for other in covered: + destination = other["dst_host"] or other["dst_ip"] or "?" + print(" %s %s -> %s:%s" % (other["id"], other["process_path"] or "(unknown process)", + destination, other["dst_port"])) + else: + print("queued: delete rule '%s' on %s. No queued connection recorded that rule, so " + "nothing to re-queue: the daemon will ask again the next time the program " + "connects" % (rule_name, node)) return 0 @@ -487,7 +508,9 @@ def add_command(name, **kwargs): undo = add_command("undo", help="take a decision back: withdraw its rule, re-queue " "the connection") - undo.add_argument("id", type=int) + undo.add_argument("what", metavar="ID|NAME", + help="a queue id from 'pending --decided', or a rule name from 'rules'") + undo.add_argument("--node", help="which daemon, when the name alone does not say") undo.set_defaults(func=cmd_undo) review_cmd = add_command("review", help="go through the queue one by one") diff --git a/ui/tests/cli/test_commands.py b/ui/tests/cli/test_commands.py index 5ed5e12661..465c587c11 100644 --- a/ui/tests/cli/test_commands.py +++ b/ui/tests/cli/test_commands.py @@ -174,6 +174,37 @@ def test_every_connection_the_rule_settled_comes_back(self, db, config, connecti assert len(queued(db)) == 1, "one rule, one delete" assert "2 connection(s) back" in capsys.readouterr().out + def test_a_rule_name_works_too(self, db, config, connection, capsys): + """`rules` shows names, not ids, and that is where people look.""" + entry_id = self._decide(db, connection) + + assert run(["undo", "deny-always-simple-usr-bin-curl"], config) == 0 + + assert db.get_pending(entry_id)["state"] == dbmod.STATE_PENDING + [(ntf_type, rule)] = queued(db) + assert ntf_type == ui_pb2.DELETE_RULE + assert rule["name"] == "deny-always-simple-usr-bin-curl" + + def test_a_name_no_entry_recorded_still_withdraws_the_rule(self, db, config, capsys): + """the daemon asks again next time, so nothing to re-queue by hand.""" + db.node_seen("unix:/local", "h", "1.9.0") + + assert run(["undo", "allow-always-simple-usr-bin-wget"], config) == 0 + + [(ntf_type, rule)] = queued(db) + assert ntf_type == ui_pb2.DELETE_RULE + assert rule["name"] == "allow-always-simple-usr-bin-wget" + assert "nothing to re-queue" in capsys.readouterr().out + + def test_a_name_needs_a_node_when_it_cannot_be_inferred(self, db, config, capsys): + db.node_seen("unix:/local", "h", "1.9.0") + db.node_seen("ipv4:10.0.0.2", "h2", "1.9.0") + + assert run(["undo", "allow-always-simple-x"], config) == 1 + assert "--node" in capsys.readouterr().err + assert run(["undo", "allow-always-simple-x", "--node", "ipv4:10.0.0.2"], config) == 0 + assert db.queued_notifications()[0]["node"] == "ipv4:10.0.0.2" + def test_only_a_decided_entry_can_be_undone(self, db, config, connection, capsys): entry_id, _ = db.record_pending("unix:/local", "sig", connection, {}) assert run(["undo", str(entry_id)], config) == 1 From 4b02059e846ca43085a3518752b65ff702a4f01e Mon Sep 17 00:00:00 2001 From: Consty Date: Fri, 14 Aug 2026 22:44:24 -0700 Subject: [PATCH 24/24] cli: never cut a rule name short, and accept a unique prefix of one `rules` truncated names to 44 characters. The name is how a rule is deleted or undone, so a cut-short one could not be pasted back in. The column is now as wide as the longest name. Since the generated names are long, `undo` and `rule delete|enable|disable` also accept any prefix that fits exactly one known rule; a prefix that fits several is refused with the choices listed. Co-Authored-By: Claude Fable 5 --- ui/opensnitch/cli/README.md | 4 ++++ ui/opensnitch/cli/db.py | 16 ++++++++++++++ ui/opensnitch/cli/main.py | 36 ++++++++++++++++++++++++++++---- ui/tests/cli/test_commands.py | 39 +++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/ui/opensnitch/cli/README.md b/ui/opensnitch/cli/README.md index f2849baeeb..0fe673ae6f 100644 --- a/ui/opensnitch/cli/README.md +++ b/ui/opensnitch/cli/README.md @@ -342,9 +342,13 @@ that list, or the rule's name from `rules` — whichever you are looking at: sudo opensnitch-cli pending --decided sudo opensnitch-cli undo 7 sudo opensnitch-cli undo deny-always-simple-usr-bin-curl # the same thing, by name +sudo opensnitch-cli undo deny-always-simple-usr # a prefix will do, if only one rule fits sudo opensnitch-cli review # it is back, decide again ``` +`rule delete|enable|disable` take a prefix the same way. One that fits several +rules is refused and the choices listed. + If one approval settled several queued connections (see "Approve it" above), undoing any of them brings all of them back, since the one rule that answered them is going away. diff --git a/ui/opensnitch/cli/db.py b/ui/opensnitch/cli/db.py index e05c8ee0ef..f5c2e0114d 100644 --- a/ui/opensnitch/cli/db.py +++ b/ui/opensnitch/cli/db.py @@ -302,6 +302,22 @@ def decided_by(self, node, rule_name): continue return found + def decided_rule_names(self): + """every rule name a decision has recorded, on any node.""" + with self._lock: + rows = self._db.execute( + "SELECT decided_rule FROM pending WHERE state=? AND decided_rule IS NOT NULL", + (STATE_DECIDED,)).fetchall() + names = set() + for row in rows: + try: + name = json.loads(row["decided_rule"]).get("name") + except (ValueError, AttributeError): + continue + if name: + names.add(name) + return names + def reopen(self, entry_ids): """puts decided entries back in the queue, as if never answered.""" with self._lock: diff --git a/ui/opensnitch/cli/main.py b/ui/opensnitch/cli/main.py index 42a96808fc..7ba25fde5d 100644 --- a/ui/opensnitch/cli/main.py +++ b/ui/opensnitch/cli/main.py @@ -147,7 +147,14 @@ def cmd_undo(args, config): return 1 node = entry["node"] else: - rule_name = args.what + known = db.decided_rule_names() + for n in db.nodes(): + known |= db.rule_names(n["addr"]) + try: + rule_name = resolve_rule_name(args.what, known) + except CommandError as e: + print("opensnitch-cli: %s" % e, file=sys.stderr) + return 1 # a rule name is only unique on one node; the entries it decided say # which, otherwise --node has to nodes = sorted(set(e["node"] for e in db.decided_by(None, rule_name))) @@ -338,14 +345,34 @@ def cmd_rules(args, config): if len(entries) == 0: print("no rules known. They are read from each node when it connects.") return 0 - print("%-8s %-44s %-7s %-14s %s" % ("ENABLED", "NAME", "ACTION", "DURATION", "MATCH")) + # the name is how a rule is deleted or undone, so it is never cut short: + # the column is as wide as the longest one + width = max([len(rule["name"]) for rule in entries] + [4]) + print("%-8s %-*s %-7s %-14s %s" % ("ENABLED", width, "NAME", "ACTION", "DURATION", "MATCH")) for rule in entries: - print("%-8s %-44s %-7s %-14s %s" % ( - "yes" if rule["enabled"] else "no", rule["name"][:44], rule["action"], + print("%-8s %-*s %-7s %-14s %s" % ( + "yes" if rule["enabled"] else "no", width, rule["name"], rule["action"], rule["duration"], _rule_match_summary(rule))) return 0 +def resolve_rule_name(given, known): + """the rule meant by a name typed on the command line. + + An exact name wins. Otherwise a prefix that fits exactly one known rule + is enough — the generated names are long, and cutting one short is what + people naturally do. A prefix that fits several is refused, listing them. + """ + if given in known: + return given + matches = sorted(n for n in known if n.startswith(given)) + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise CommandError("'%s' could be any of: %s" % (given, ", ".join(matches))) + return given + + def cmd_rule(args, config): """delete / enable / disable a rule on the daemon, by name.""" from google.protobuf import json_format @@ -355,6 +382,7 @@ def cmd_rule(args, config): db = open_db(config) try: node = resolve_node(db, args.node) + args.name = resolve_rule_name(args.name, db.rule_names(node)) except CommandError as e: print("opensnitch-cli: %s" % e, file=sys.stderr) return 1 diff --git a/ui/tests/cli/test_commands.py b/ui/tests/cli/test_commands.py index 465c587c11..63581b5e24 100644 --- a/ui/tests/cli/test_commands.py +++ b/ui/tests/cli/test_commands.py @@ -64,6 +64,26 @@ def test_delete_queues_a_delete_for_the_daemon(self, db, config, capsys): # the daemon refuses a rule without an operator, even to delete it assert rule["operator"]["operand"] == "true" + def test_a_unique_prefix_of_the_name_is_enough(self, db, config): + db.node_seen("unix:/local", "h", "1.9.0") + db.replace_rules("unix:/local", [a_rule("allow-always-simple-usr-bin-curl")]) + + assert run(["rule", "delete", "allow-always-simple-usr"], config) == 0 + + [(ntf_type, rule)] = queued(db) + assert ntf_type == ui_pb2.DELETE_RULE + assert rule["name"] == "allow-always-simple-usr-bin-curl" + + def test_the_full_name_is_shown_however_long(self, db, config, capsys): + """the name is how a rule is deleted; a cut-short one cannot be pasted.""" + name = "allow-always-list-usr-lib-x86-64-linux-gnu-some-very-long-binary-name-" \ + "api-example-com-443" + db.node_seen("unix:/local", "h", "1.9.0") + db.replace_rules("unix:/local", [a_rule(name)]) + + assert run(["rules"], config) == 0 + assert name in capsys.readouterr().out + def test_disable_sends_the_whole_rule_back(self, db, config): """a name alone would make the daemon replace the rule with an empty one (notifications.go handleActionEnableRule): the real rule has to @@ -185,6 +205,25 @@ def test_a_rule_name_works_too(self, db, config, connection, capsys): assert ntf_type == ui_pb2.DELETE_RULE assert rule["name"] == "deny-always-simple-usr-bin-curl" + def test_a_unique_prefix_of_the_name_is_enough(self, db, config, connection): + entry_id = self._decide(db, connection) + + assert run(["undo", "deny-always-simple-usr"], config) == 0 + + assert db.get_pending(entry_id)["state"] == dbmod.STATE_PENDING + [(_, rule)] = queued(db) + assert rule["name"] == "deny-always-simple-usr-bin-curl" + + def test_an_ambiguous_prefix_is_refused_with_the_choices(self, db, config, connection, capsys): + self._decide(db, connection, sig="a", rule_name="deny-always-simple-usr-bin-curl") + self._decide(db, connection, sig="b", rule_name="deny-always-simple-usr-bin-wget") + + assert run(["undo", "deny-always-simple-usr"], config) == 1 + err = capsys.readouterr().err + assert "could be any of" in err + assert "usr-bin-curl" in err and "usr-bin-wget" in err + assert queued(db) == [] + def test_a_name_no_entry_recorded_still_withdraws_the_rule(self, db, config, capsys): """the daemon asks again next time, so nothing to re-queue by hand.""" db.node_seen("unix:/local", "h", "1.9.0")