diff --git a/build_files/firewall/snitchwatch-system-verify.py b/build_files/firewall/snitchwatch-system-verify.py index 1d99efa..76793d0 100644 --- a/build_files/firewall/snitchwatch-system-verify.py +++ b/build_files/firewall/snitchwatch-system-verify.py @@ -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']) diff --git a/docs/research/snitchwatch-system-bridge.md b/docs/research/snitchwatch-system-bridge.md index 7aca84d..8c4ecda 100644 --- a/docs/research/snitchwatch-system-bridge.md +++ b/docs/research/snitchwatch-system-bridge.md @@ -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. @@ -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. @@ -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 diff --git a/system_files/usr/libexec/bazzite-tower-snitchwatch-common.py b/system_files/usr/libexec/bazzite-tower-snitchwatch-common.py index 215e148..2c4f754 100644 --- a/system_files/usr/libexec/bazzite-tower-snitchwatch-common.py +++ b/system_files/usr/libexec/bazzite-tower-snitchwatch-common.py @@ -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 diff --git a/system_files/usr/share/bazzite-tower/snitchwatch/opensnitch.service.d/20-system-bridge.conf b/system_files/usr/share/bazzite-tower/snitchwatch/opensnitch.service.d/20-system-bridge.conf index 17e131d..69aec36 100644 --- a/system_files/usr/share/bazzite-tower/snitchwatch/opensnitch.service.d/20-system-bridge.conf +++ b/system_files/usr/share/bazzite-tower/snitchwatch/opensnitch.service.d/20-system-bridge.conf @@ -1,5 +1,5 @@ [Unit] -Requires=snitchwatch-system-bridge-grpc.socket +Wants=snitchwatch-system-bridge-grpc.socket After=snitchwatch-system-bridge-grpc.socket [Service] diff --git a/tests/boot-check.sh b/tests/boot-check.sh index 3e41aed..a8da855 100755 --- a/tests/boot-check.sh +++ b/tests/boot-check.sh @@ -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 "]' diff --git a/tests/test-snitchwatch-image-build.py b/tests/test-snitchwatch-image-build.py index b27491a..7e0957a 100644 --- a/tests/test-snitchwatch-image-build.py +++ b/tests/test-snitchwatch-image-build.py @@ -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) diff --git a/tests/test-snitchwatch-system.py b/tests/test-snitchwatch-system.py index 6cc20c9..25010e2 100644 --- a/tests/test-snitchwatch-system.py +++ b/tests/test-snitchwatch-system.py @@ -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") @@ -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.