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. + +"""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([])