Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build_files/firewall/snitchwatch-system-verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def verify(root):
raise ValueError('system candidate changes more than the daemon address')
if config.get('DefaultAction') != 'allow' or config.get('ProcMonitorMethod') != 'proc' or config.get('Server', {}).get('Address') != 'unix:opensnitchd.sock':
raise ValueError('incorrect system candidate policy/transport')
expected_dropin = b'[Unit]\nRequires=snitchwatch-system-bridge-grpc.socket\nAfter=snitchwatch-system-bridge-grpc.socket\n\n[Service]\nWorkingDirectory=/run/snitchwatch\n'
expected_dropin = b'[Unit]\nWants=snitchwatch-system-bridge-grpc.socket\nAfter=snitchwatch-system-bridge-grpc.socket\n\n[Service]\nWorkingDirectory=/run/snitchwatch\n'
if contents[DROPIN] != expected_dropin or contents['/usr/share/bazzite-tower/snitchwatch/opensnitch.service.d/20-system-bridge.conf'] != expected_dropin:
raise ValueError('incorrect daemon system-socket ordering/drop-in')
artifact = json.loads(contents['/usr/share/snitchwatch/schema1-artifact-MANIFEST.json'])
Expand Down
12 changes: 10 additions & 2 deletions docs/research/snitchwatch-system-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ leaving a slash-containing HTTP/2 authority that the bridge rejects with
then subscribed successfully with this configuration:

- `Server.Address: unix:opensnitchd.sock`
- `opensnitch.service`: `WorkingDirectory=/run/snitchwatch`, with `Requires=`
- `opensnitch.service`: `WorkingDirectory=/run/snitchwatch`, with `Wants=`
and `After=` on `snitchwatch-system-bridge-grpc.socket`.

This VM-only workaround retains the absolute root-owned 0600 listener and root-peer check.
Expand Down Expand Up @@ -456,7 +456,9 @@ Open items:
send point). The daemon now accepts exactly those two notification actions
in the system variant (others get an error reply) and refuses `lists.*`
operands from the UI channel until Snitchwatch blocklists (#45 PR B) define
a confined directory under `/var/lib/snitchwatch/blocklists/`.
a confined directory under `/var/lib/snitchwatch/blocklists/`. The owner confirmed on
2026-10-08 that this two-action allowlist is policy: any further action
needs its own change, with validation and review.
- ~~The daemon factory and CI do not run the patch's Go tests.~~ Fixed
2026-10-07: `just test-snitchwatch-daemon-patch` and the path-filtered
`snitchwatch-daemon-patch.yml` workflow run them, `-race` included.
Expand Down Expand Up @@ -568,6 +570,12 @@ a prompt pending before a pause keeps the prompt slot (Snitchwatch #78), and
stopping `snitchwatch-system-bridge-grpc.socket` also stops `opensnitch.service`
(its drop-in `Requires=` the socket) until it is started by hand. Evidence:
`output/snitchwatch-fresh-vm-r8.xZd346/R8-VM-ACCEPTANCE-RESULT.json`.
The owner chose to keep the firewall up: the drop-in now uses `Wants=` (with
the same `After=`), so the socket is still pulled in and ordered first at boot,
but stopping it no longer stops the daemon, which falls back to its default
action while no UI is connected. The build verifier, readiness helper and boot
check now require `Wants=`. Daemon reconnection after the socket comes back
is checked on the r9 VM.

The fixed target-image acceptance limits are 5 seconds for no-GUI fallback,
2 seconds for pending cleanup and 15 seconds for daemon stop. Preserve the
Expand Down
6 changes: 4 additions & 2 deletions system_files/usr/libexec/bazzite-tower-snitchwatch-common.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,13 @@ def unit_contract(ctx):
props = ctx.props(unit, ["Listen", "SocketUser", "SocketGroup", "SocketMode", "Accept", "Triggers", "FragmentPath", "DropInPaths", "NeedDaemonReload"])
loaded_current(unit, props)
require({k: v for k, v in props.items() if k != "NeedDaemonReload"} == dict(Listen=path+" (Stream)", SocketUser="root", SocketGroup=group, SocketMode=mode, Accept="no", Triggers=SERVICE, FragmentPath="/usr/lib/systemd/system/"+unit, DropInPaths=""), "effective socket contract drift: "+unit)
daemon = ctx.props("opensnitch.service", ["WorkingDirectory", "Requires", "After", "ExecStart", "FragmentPath", "DropInPaths", "NeedDaemonReload"])
daemon = ctx.props("opensnitch.service", ["WorkingDirectory", "Wants", "After", "Requires", "Requisite", "BindsTo", "PartOf", "ExecStart", "FragmentPath", "DropInPaths", "NeedDaemonReload"])
loaded_current("opensnitch.service", daemon)
daemon_unit_contract(ctx, daemon)
daemon_exec_contract(daemon["ExecStart"])
require(daemon["WorkingDirectory"] == "/run/snitchwatch" and SOCKETS[0] in daemon["Requires"].split() and SOCKETS[0] in daemon["After"].split(), "OpenSnitch effective Unix socket CWD/dependencies drift")
require(daemon["WorkingDirectory"] == "/run/snitchwatch" and SOCKETS[0] in daemon["Wants"].split() and SOCKETS[0] in daemon["After"].split(), "OpenSnitch effective Unix socket CWD/dependencies drift")
# A stop of the gRPC socket must never take the firewall down with it.
require(not any(SOCKETS[0] in daemon[key].split() for key in ("Requires", "Requisite", "BindsTo", "PartOf")), "OpenSnitch must only Want the bridge gRPC socket: a socket stop would stop the firewall")
return actual


Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[Unit]
Requires=snitchwatch-system-bridge-grpc.socket
Wants=snitchwatch-system-bridge-grpc.socket
After=snitchwatch-system-bridge-grpc.socket

[Service]
Expand Down
3 changes: 2 additions & 1 deletion tests/boot-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ assert set(p.get("TriggeredBy","").split())=={"snitchwatch-system-bridge-grpc.so
import re, subprocess
p=dict(line.split("=",1) for line in subprocess.check_output(["systemctl","show","opensnitch.service"],text=True,timeout=5).splitlines() if "=" in line)
assert p.get("WorkingDirectory")=="/run/snitchwatch"
assert "snitchwatch-system-bridge-grpc.socket" in p.get("Requires","").split()
assert "snitchwatch-system-bridge-grpc.socket" in p.get("Wants","").split()
assert not any("snitchwatch-system-bridge-grpc.socket" in p.get(k,"").split() for k in ("Requires","Requisite","BindsTo","PartOf"))
assert "snitchwatch-system-bridge-grpc.socket" in p.get("After","").split()
assert re.findall(r"(?:^|[ {])path=([^ ;}]+)",p.get("ExecStart",""))==["/usr/bin/opensnitchd"]
assert re.findall(r"argv\[\]=([^;]+)",p.get("ExecStart",""))==["/usr/bin/opensnitchd "]'
Expand Down
2 changes: 1 addition & 1 deletion tests/test-snitchwatch-image-build.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def make_fixture(self):
self.write('usr/share/bazzite-tower/snitchwatch-bridge-profile', 'system\n')
self.write('usr/share/bazzite-tower/opensnitchd-default-config.json', (ROOT / 'system_files/usr/share/bazzite-tower/opensnitchd-default-config.json').read_bytes())
self.write('usr/share/bazzite-tower/opensnitchd-system-bridge-config.json', (ROOT / 'system_files/usr/share/bazzite-tower/opensnitchd-system-bridge-config.json').read_bytes())
dropin = '[Unit]\nRequires=snitchwatch-system-bridge-grpc.socket\nAfter=snitchwatch-system-bridge-grpc.socket\n\n[Service]\nWorkingDirectory=/run/snitchwatch\n'
dropin = '[Unit]\nWants=snitchwatch-system-bridge-grpc.socket\nAfter=snitchwatch-system-bridge-grpc.socket\n\n[Service]\nWorkingDirectory=/run/snitchwatch\n'
for name in ['usr/lib/systemd/system/opensnitch.service.d/20-system-bridge.conf', 'usr/share/bazzite-tower/snitchwatch/opensnitch.service.d/20-system-bridge.conf']:
self.write(name, dropin)
self.write('usr/libexec/snitchwatch/verify-system-manifest.py', (FACTORY / 'snitchwatch-system-verify.py').read_bytes(), 0o755)
Expand Down
11 changes: 10 additions & 1 deletion tests/test-snitchwatch-system.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def __init__(self, root):
ExecStart="{ path=/usr/bin/snitchwatch-bridge-cli ; argv[]=/usr/bin/snitchwatch-bridge-cli ; ignore_errors=no ; }",
Environment="SNITCHWATCH_SYSTEM_BRIDGE=1 SNITCHWATCH_WS_SOCKET=/run/snitchwatch/bridge.sock SNITCHWATCH_WS_TOKEN_PATH=/run/snitchwatch-auth/token HOME=/var/lib/snitchwatch XDG_STATE_HOME=/var/lib",
TriggeredBy=" ".join(common.SOCKETS), RestrictAddressFamilies="AF_UNIX AF_INET AF_INET6", MainPID="0", NeedDaemonReload="no"),
"opensnitch.service": dict(MainPID="501", ExecStart="{ path=/usr/bin/opensnitchd ; argv[]=/usr/bin/opensnitchd ; ignore_errors=no ; }", WorkingDirectory="/run/snitchwatch", Requires=common.SOCKETS[0]+" network.target", After=common.SOCKETS[0]+" network.target", NeedDaemonReload="no",
"opensnitch.service": dict(MainPID="501", ExecStart="{ path=/usr/bin/opensnitchd ; argv[]=/usr/bin/opensnitchd ; ignore_errors=no ; }", WorkingDirectory="/run/snitchwatch", Wants=common.SOCKETS[0]+" network.target", After=common.SOCKETS[0]+" network.target", Requires="", Requisite="", BindsTo="", PartOf="", NeedDaemonReload="no",
FragmentPath="/usr/lib/systemd/system/opensnitch.service", DropInPaths="/usr/lib/systemd/system/opensnitch.service.d/20-system-bridge.conf")}
for unit, path, group, mode in ((common.SOCKETS[0], "/run/snitchwatch/opensnitchd.sock", "root", "0600"), (common.SOCKETS[1], "/run/snitchwatch/bridge.sock", "snitchwatch-ui", "0660")):
self.properties[unit] = dict(Listen=path+" (Stream)", SocketUser="root", SocketGroup=group, SocketMode=mode, Accept="no", Triggers=common.SERVICE, FragmentPath="/usr/lib/systemd/system/"+unit, DropInPaths="", NeedDaemonReload="no")
Expand Down Expand Up @@ -349,6 +349,15 @@ def test_units_changed_on_disk_since_load_refuse(self):
try: self.refuse_readiness("daemon-reload")
finally: self.ctx.properties[unit]["NeedDaemonReload"] = "no"

def test_hard_dependency_on_grpc_socket_refuses(self):
for key in ("Requires", "Requisite", "BindsTo", "PartOf"):
with self.subTest(key=key):
daemon = self.ctx.properties["opensnitch.service"]
daemon[key] = common.SOCKETS[0]
with self.assertRaisesRegex(common.Refusal, "only Want the bridge gRPC socket"):
common.unit_contract(self.ctx)
daemon[key] = ""

def test_daemon_unit_file_and_dropins_are_pinned(self):
# NeedDaemonReload only proves systemd loaded what is on disk; it says
# nothing about which file and drop-ins it loaded for the daemon.
Expand Down