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/README.md b/ui/opensnitch/cli/README.md
new file mode 100644
index 0000000000..0fe673ae6f
--- /dev/null
+++ b/ui/opensnitch/cli/README.md
@@ -0,0 +1,461 @@
+# 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
+```
+
+```
+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
+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.
+
+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
+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] [--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\|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 |
+| `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. `--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:
+
+```bash
+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.
+
+## 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` 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 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.
+
+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
+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.
+
+**`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`.
+
+**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. 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
+`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`
+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.
+* 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.
+
+## Packaging
+
+`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
+
+```bash
+cd ui/tests && pytest -v cli/
+```
+
+They need neither Qt nor a display.
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..f63af9dab0
--- /dev/null
+++ b/ui/opensnitch/cli/config.py
@@ -0,0 +1,165 @@
+# 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": "deny",
+ "unreviewed_duration": "1h",
+ "default_action": "deny",
+ "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 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/db.py b/ui/opensnitch/cli/db.py
new file mode 100644
index 0000000000..f5c2e0114d
--- /dev/null
+++ b/ui/opensnitch/cli/db.py
@@ -0,0 +1,555 @@
+# 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 = 2
+
+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,
+ rule_json TEXT,
+ 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 > 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:
+ 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 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(
+ "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.
+ """
+ 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(query, args).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 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:
+ 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.
+
+ 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, 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(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:
+ 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))
+ if ok:
+ self.reflect_result(ntf_id)
+
+ def requeue_sent(self, node=None):
+ """puts unanswered notifications back in the queue.
+
+ 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(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()
+
+ def outbox_errors(self):
+ with self._lock:
+ 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):
+ """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,))
+ 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"
+ 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):
+ """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()
+ 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:
+ 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",
+ (STATE_PENDING, cutoff))
+ removed = cur.rowcount
+ self._db.execute("DELETE FROM outbox WHERE state=? AND updated", (OUT_DONE, cutoff))
+ return removed
diff --git a/ui/opensnitch/cli/durations.py b/ui/opensnitch/cli/durations.py
new file mode 100644
index 0000000000..4c75f45fe3
--- /dev/null
+++ b/ui/opensnitch/cli/durations.py
@@ -0,0 +1,98 @@
+# 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 .
+
+"""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
+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
+import time
+
+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 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:
+ 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/main.py b/ui/opensnitch/cli/main.py
new file mode 100644
index 0000000000..7ba25fde5d
--- /dev/null
+++ b/ui/opensnitch/cli/main.py
@@ -0,0 +1,615 @@
+# 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 sys
+import time
+
+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'
+
+
+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.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 _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)
+ 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 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"))
+ for entry in entries:
+ print(_entry_summary(entry))
+ return 0
+
+
+def cmd_undo(args, config):
+ """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(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:
+ 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)))
+ 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 several queued connections; they all come
+ # back, since the rule that answered them is going away
+ 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(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])
+
+ 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
+
+
+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:
+ 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
+
+
+# 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, 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 is_served(n, now)]
+
+
+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
+
+ 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:
+ 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()
+ 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 _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:
+ 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
+ # 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 %-*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
+ from opensnitch.cli.proto import ui_pb2
+ from opensnitch.cli import rules
+
+ 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
+
+ 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
+
+
+def cmd_nodes(args, config):
+ db = open_db(config)
+ nodes = db.nodes()
+ now = time.time()
+ if args.json:
+ 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("%-24s %-18s %-9s %-7s %s" % ("ADDRESS", "HOSTNAME", "DAEMON", "ONLINE", "LAST SEEN"))
+ for node in nodes:
+ 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
+
+
+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(served_nodes(db))
+ errors = db.outbox_errors()
+ queued = db.queued_count()
+
+ 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 _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)
+ _add_common_options(parser, default=None)
+
+ subparsers = parser.add_subparsers(dest="command")
+
+ 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 = 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.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("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")
+ 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 = 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 = 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 = 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 = 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 = add_command("nodes", help="daemons that have connected")
+ nodes.add_argument("--json", action="store_true")
+ nodes.set_defaults(func=cmd_nodes)
+
+ 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",
+ 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
+
+
+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.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/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
new file mode 100644
index 0000000000..bccc738a02
--- /dev/null
+++ b/ui/opensnitch/cli/policy.py
@@ -0,0 +1,224 @@
+# 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, match, 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)
+
+ 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, "
+ "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. 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:
+ 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
+
+ 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/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_()
diff --git a/ui/opensnitch/cli/review.py b/ui/opensnitch/cli/review.py
new file mode 100644
index 0000000000..ed0cbf56c7
--- /dev/null
+++ b/ui/opensnitch/cli/review.py
@@ -0,0 +1,538 @@
+# 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 os
+import time
+
+from opensnitch import operands
+from opensnitch.rule_consts import RuleConsts
+from opensnitch.cli import durations, match, 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 _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 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:
+ 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 != "":
+ 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
+ def name(self):
+ if self._custom_name is not None:
+ return self._custom_name
+ ops = self.operators()
+ if len(ops) == 0:
+ return ""
+ return rules.unique_name(
+ 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):
+ 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:
+ 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 = []
+ 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:
+ 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"],
+ 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"]))
+ 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 == "":
+ 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)
+ elif choice == "6":
+ decision.precedence = not decision.precedence
+ else:
+ 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.
+
+ 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
+ from opensnitch.cli import db as dbmod
+
+ _queue_provisional_delete(db, entry)
+ db.queue_notification(entry["node"], ui_pb2.CHANGE_RULE,
+ json_format.MessageToJson(rule), pending_id=entry["id"])
+ 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 = 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)
+ 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
+
+ # 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
+ 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
+
+
+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)
diff --git a/ui/opensnitch/cli/rules.py b/ui/opensnitch/cli/rules.py
new file mode 100644
index 0000000000..a5917683ed
--- /dev/null
+++ b/ui/opensnitch/cli/rules.py
@@ -0,0 +1,203 @@
+# 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, 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")
+ name = slugify("%s %s" % (name, data))
+ for value in extra:
+ name = slugify("%s %s" % (name, value))
+ return name[: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..9335df767e
--- /dev/null
+++ b/ui/opensnitch/cli/server.py
@@ -0,0 +1,276 @@
+# 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 socket
+import threading
+import time
+from concurrent import futures
+
+import grpc
+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
+from opensnitch.cli.service import Service
+
+logger = logging.getLogger(__name__)
+
+OUTBOX_INTERVAL = 1.0
+LAST_SEEN_INTERVAL = 30.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
+
+
+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; 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
+ 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
+ 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)
+ 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
+ # 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 == 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):
+ 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. 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 node in self._service.nodes():
+ if node.stop.is_set():
+ continue
+ 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):
+ 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
+ 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:
+ 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..100426752d
--- /dev/null
+++ b/ui/opensnitch/cli/service.py
@@ -0,0 +1,303 @@
+# 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 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__)
+
+# 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
+
+# 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):
+ 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()
+
+ # 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: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")
+ 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:
+ return self._nodes.get(addr)
+
+ 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()
+ 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)
+
+ # 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:
+ 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)
+
+ 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):
+ """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():
+ node.stop.set()
+ self._on_stream_closed(node)
+
+ 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()
+ 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())
+ 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)
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/dialogs/prompt/constants.py b/ui/opensnitch/dialogs/prompt/constants.py
index 7ecba064fe..9cb294a471 100644
--- a/ui/opensnitch/dialogs/prompt/constants.py
+++ b/ui/opensnitch/dialogs/prompt/constants.py
@@ -1,5 +1,23 @@
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
+)
+
+# 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
@@ -8,20 +26,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 +41,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..e7de05efdc
--- /dev/null
+++ b/ui/opensnitch/operands.py
@@ -0,0 +1,289 @@
+# 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))
+
+ 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):
+ 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
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/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]
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)
diff --git a/ui/resources/cli.conf.example b/ui/resources/cli.conf.example
new file mode 100644
index 0000000000..eb43db7bcb
--- /dev/null
+++ b/ui/resources/cli.conf.example
@@ -0,0 +1,81 @@
+# 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 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 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 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
+# 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. 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
new file mode 100644
index 0000000000..104c4ca6ea
--- /dev/null
+++ b/ui/resources/init/opensnitch-cli.service
@@ -0,0 +1,32 @@
+[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
+# /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,
+# 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)
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..9dc8837afc
--- /dev/null
+++ b/ui/tests/cli/conftest.py
@@ -0,0 +1,48 @@
+# 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 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_commands.py b/ui/tests/cli/test_commands.py
new file mode 100644
index 0000000000..63581b5e24
--- /dev/null
+++ b/ui/tests/cli/test_commands.py
@@ -0,0 +1,312 @@
+#
+# pytest -v cli/test_commands.py
+#
+# 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
+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 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):
+ 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_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
+ 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
+
+
+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_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_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")
+
+ 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
+ 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):
+ """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_db.py b/ui/tests/cli/test_db.py
new file mode 100644
index 0000000000..5b14a817ba
--- /dev/null
+++ b/ui/tests/cli/test_db.py
@@ -0,0 +1,236 @@
+#
+# pytest -v cli/test_db.py
+#
+
+import json
+
+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_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"}')
+ 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")
+
+
+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:
+
+ 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_endtoend.py b/ui/tests/cli/test_endtoend.py
new file mode 100644
index 0000000000..c834528f04
--- /dev/null
+++ b/ui/tests/cli/test_endtoend.py
@@ -0,0 +1,300 @@
+#
+# 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_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()
+ 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_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
+ 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()
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_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..bec0f20bdb
--- /dev/null
+++ b/ui/tests/cli/test_policy.py
@@ -0,0 +1,228 @@
+#
+# 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 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.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):
+ 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.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
+
+
+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
new file mode 100644
index 0000000000..cc388bd354
--- /dev/null
+++ b/ui/tests/cli/test_review.py
@@ -0,0 +1,457 @@
+#
+# pytest -v cli/test_review.py
+#
+
+import json
+
+from opensnitch import operands
+from opensnitch.cli import 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 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]
+
+ 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:
+
+ 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_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.
+
+ 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 = []
+ # 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 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):
+ 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..7e38051354
--- /dev/null
+++ b/ui/tests/cli/test_rules.py
@@ -0,0 +1,136 @@
+#
+# 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_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"
+
+
+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..a9c20f81eb
--- /dev/null
+++ b/ui/tests/cli/test_service.py
@@ -0,0 +1,246 @@
+#
+# 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_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"
+ 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 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:
+
+ 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.
+
+ 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.set("policy", "default_action", "allow")
+ 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 == "deny"
+ 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_allow_policy(self, db, config, connection):
+ config.set("policy", "unreviewed_action", "allow")
+ service = make_service(db, config)
+ assert service.AskRule(connection, FakeContext()).action == "allow"
+
+
+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
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([])