opensnitch-store-path-rules¶
Modules
Per-process egress rules for OpenSnitch, written as Nix expressions, that keep matching after the package is rebuilt.
OpenSnitch identifies a process by its absolute executable path. On NixOS that
path is content-addressed — /nix/store/<32-char-hash>-curl-8.14.1/bin/curl —
and the hash changes on every upgrade, every patch, every stdenv bump. A rule
that names the path you have today is a rule that stops matching tomorrow, at
which point the program either prompts forever or (with
DefaultAction = "deny") has its connections silently dropped.
This module generates rules that match the shape of a store path instead of one instance of it, and lints the patterns so the classic mistakes fail at evaluation instead of at 03:00 when a backup stops uploading.
Ships with a NixOS VM test (test.nix) that runs opensnitchd under
DefaultAction = "deny" with the eBPF process monitor and proves, over real
HTTP requests between two VMs, that the generated rule keeps allowing the target
binary across three rebuilds while a hand-written literal-path rule stops
matching after the first one. See Tests.
The problem¶
services.opensnitch.rules in nixpkgs is a freeform JSON passthrough. Its type
is a submodule with freeformType = format.type
(nixos/modules/services/security/opensnitch.nix:52), each rule is serialised
with pkgs.writeText "rule" (builtins.toJSON cfg) (line 13) and symlinked into
/var/lib/opensnitch/rules. Upstream takes no position at all on what goes into
operator.data, and the option's own example (lines 33–41) does the thing that
breaks:
"operator" = {
"type" = "simple";
"operand" = "process.path";
"data" = "${lib.getBin pkgs.tor}/bin/tor";
};
That is an exact store path. It is not wrong in the sense of being unmaintainable — Nix will re-interpolate it on the next rebuild — but it has four consequences nobody reads out of the example:
- It only matches if
getBin/getExereturns the path the kernel actually execs. For anything nixpkgs wraps, it does not. See Trap 2. - It pulls the package into the system closure. The generated rule file
depends on
pkgs.tor, sotoris now a dependency of your/etcand gets built and copied to the host — even if you never installed it and only ever run it throughnix run. - It churns. Every version bump rewrites the rule file, which rewrites the
etcderivation, which is fine — until you notice consequence 4. - It has a live-invalidation window.
nixos-rebuild switchinstalls the new rule while the running process is still executing from the old store path. opensnitchd reloads the rules directory on change, the running browser no longer matches its own rule, and its next connection is denied. The program keeps running and quietly loses the network until it restarts.
A pattern rule has none of the four. It references no package, so the closure is untouched; it does not change when the package changes, so nothing churns and there is no invalidation window; and it can be written to cover the wrapper and the payload.
Traps¶
Trap 1 — the mechanism, and exactly how far it goes¶
Every generated process.path operand has type = "regexp" and matches on the
components of a store path that are stable across rebuilds:
^/nix/store/[0-9a-z]+-tailscale-[^/]*/bin/(\.)?tailscaled(-wrapped)?$
└ literal ┘└ hash ┘ └ pname ┘└ version ┘└──── payload path ─────┘
The hash is the only part that moves; everything else is derivation metadata you
already control. binaries.<name> builds that string from pname, a version
pattern, and the path inside the output.
What this buys you: rules survive nix flake update, channel bumps, mass
rebuilds, and --rebuild. The generated JSON is a fixed point — it does not
change when the package changes, which also means nixos-rebuild produces no
opensnitch diff at all on a normal upgrade.
What it costs you — and this is the real limit: the rule authorises a
class of store paths, not a specific artifact. Anything on the machine whose
derivation name matches tailscale-* and that ships bin/tailscaled gets the
same permission, including something a user builds in nix develop. Store paths
are unforgeable addresses, not authenticators, and matching them by shape
gives up the one property that made them exact. If you need the strong version,
use rules.<name>.processPath with a pinned store path, accept the churn, and
re-read Trap 1's consequence 4 before you deploy it.
There is no middle ground available: OpenSnitch has no notion of a signature, a Nix output hash, or an "any path produced by this derivation" predicate. The choice is class of paths or one path that expires.
Trap 2 — wrappers: the binary you name is not the binary that runs¶
makeWrapper/wrapProgram move the real ELF to bin/.NAME-wrapped and put a
launcher at bin/NAME. The launcher execs the payload, so by the time the
socket is created, /proc/<pid>/exe points at the dot-prefixed file. A rule
written for bin/NAME never matches. Worse, lib.getExe returns bin/NAME, so
the "obvious" declarative rule is wrong in exactly this case.
Hence wrapped = true, which widens the leaf to (\.)?NAME(-wrapped)? and
covers both. Set it for anything that is wrapped, and expect to be surprised by
what is: GUI apps, Qt apps, anything with QT_PLUGIN_PATH, anything with a
PATH fixup, Go binaries that need LD_LIBRARY_PATH, and CLI tools whose
package adds a --config default.
A real incident. A recording tool's derivation installs two wrapped binaries — the main one and a secondary uploader. The rule was written for the main one:
The uploader is bin/.rec-tab-wrapped, which that regex does not match. It ran,
it produced files, and its uploads were dropped — no error in the tool, no
prompt, just a transfer that never completed. Fixing it is one alternation:
The lesson is procedural, not textual: after writing a rule, ls the package's
bin/ and enumerate what is actually there.
$ ls -a "$(nix build --no-link --print-out-paths nixpkgs#somepkg)/bin"
. .. .somepkg-wrapped .somepkg-helper-wrapped somepkg somepkg-helper
Trap 3 — RE2 semantics, three ways to be too loose¶
opensnitchd compiles rule regexes with Go's regexp (RE2). Three defaults bite:
.matches/. RE2 has no "except separator" default. So a version pattern of-.*will happily run across directory boundaries:^/nix/store/[a-z0-9]+-foo-.*/bin/foo$also matches/nix/store/<hash>-foo-1.0/share/vendor/evil/bin/foo. This module's defaultversionPatternis therefore-[^/]*, which cannot leave the first component. Set-.*only where you need it — some packages genuinely bury the executable under a second versioned directory (lib/firefox-<ver>/firefox) — and know you have widened the match when you do.- A regex without anchors is a substring match.
bin/ncmatches.../bin/ncat;curlmatches.../bin/curlftpfs.lint.anchors(default on) asserts^…$on every pattern this module emits or is handed. .*in the hash position is a wildcard over the whole path.^/nix/store/.*-curl-will match any store path that has-curl-anywhere in it, including a completely unrelated package withcurlin a subdirectory name. ThestoreHashPatterndefault is[0-9a-z]+, which contains neither-nor/and therefore cannot escape the hash field.
The lints are options, not law — lint.anchors = false exists — but the
defaults are the safe ones and turning them off is a decision you will see in
the diff.
Trap 4 — the destination is a moving target; the process is not¶
This is the one that cost real downtime.
An antivirus updater (freshclam) was allowed with a deliberately tight rule:
process regex AND dest.host == database.<vendor>.net AND
dest.port == 443. It worked, then stopped, silently:
- the version check (a DNS TXT lookup) still succeeded, because the resolvers had their own rule;
- the actual CVD download timed out after roughly 132 bytes of traffic;
- the AV daemon then had no signature database at all — a security control disabled by a security control.
Two things had changed. The vendor put the database host behind a CDN, so the
name is now a CNAME into *.cdn.<cdn-vendor>.net — and the hostname OpenSnitch
attributes to a connection comes from its DNS-answer cache, so for a CNAME chain
it can be the final target, not the name the application asked for. And the
updater falls back to plain :80, which the port operand forbade.
The fix widened the destination and dropped the port entirely:
freshclam = {
binary = "freshclam";
hostRegex = "^(database\\.example\\.net|([a-z0-9-]+\\.)*cdn\\.example\\.net)$";
};
Diagnosis recipe, in order:
systemctl stop opensnitchdand retry. If it works, it is a rule, full stop.journalctl -u opensnitchd -fwhile retrying — the daemon logs the process path and the destination it actually saw. That string is the ground truth for both operands, and it is frequently not what you assumed.- Only then edit the rule.
The general rule this yields: constrain process.path tightly and
dest.host loosely. The process identity is the security boundary and you
control it. The destination is somebody else's infrastructure decision, and it
will be re-pointed at a CDN, split across ports, or sharded across regions
without telling you. A port operand on an updater buys almost nothing and breaks
on the first HTTP fallback.
Trap 5 — an interpreter rule is a rule for every script¶
process.path for a Python program is the interpreter:
/nix/store/<hash>-python3-3.13.7/bin/python3. So a rule that allows "the
Python tool that syncs my NAS" allows every Python program on the machine,
including one a user pip-installs into a venv, because they all exec the same
interpreter. Identical story for node, java, ruby, and for anything
launched through a shell wrapper that execs an interpreter.
Mitigations, in descending order of effectiveness:
- Constrain the destination as well — an interpreter rule scoped to one
dest.hostand one port is a much smaller grant than a blanket one. - Use
extraOperandswithprocess.command(matches the full argv, so it can pin the script path). Note this is a string match on a value the process itself influences, so treat it as hygiene rather than as a boundary. - Package the tool so it gets its own wrapper in its own store path, and match that instead. This is the only option that gives back a real process identity.
Trap 6 — created must be a constant¶
Every rule carries a created timestamp. It is tempting to fill it from
builtins.currentTime. Do not: it makes the generated JSON change on every
evaluation, so every nixos-rebuild produces a new rule store path, a new
etc, a new system generation, and — because opensnitchd watches the rules
directory — a live rule reload for no reason. The module therefore takes a
single fixed created string and stamps it into everything. The default is the
epoch; pin your own if you want the field to mean something.
Trap 7 — rules made in the GUI outlive the declarative ones¶
Upstream's preStart garbage-collects stale rules like this
(nixos/modules/services/security/opensnitch.nix:207):
-type l — symlinks only. A rule you accepted in the OpenSnitch UI ("allow
forever") is written by the daemon as a regular file, typically named
000-allow-<something>.json. It is never deleted by a rebuild, it is loaded on
every start, and it silently shadows your declarative intent for as long as the
host lives. A host that has been driven interactively for a while and is then
converted to declarative rules will behave nothing like its expression.
Before trusting a declarative ruleset:
$ find /var/lib/opensnitch/rules -type f # NOT symlinks = imperative leftovers
$ find /var/lib/opensnitch/rules -type l -ls # the ones this module owns
Delete the regular files once you have translated anything worth keeping.
Trap 8 — DefaultAction = "deny" fails closed and fails quietly¶
A denied connection is dropped, not rejected: no RST, no ICMP, no ECONNREFUSED.
The application sees a connect timeout, minutes later, and usually reports it as
a network problem. The signature vocabulary worth memorising:
| Symptom | Likely cause |
|---|---|
| Name resolves, TCP hangs, a few hundred bytes counted | process rule matched for DNS (resolver has its own rule) but not for the app |
| Prompt appears on every launch, "always" never sticks | the path changed since the rule was written — the GUI rule was exact |
| Works as root, fails as the service | different binary: bin/foo vs bin/.foo-wrapped (Trap 2) |
| Worked for months, broke with no local change | the destination moved (Trap 4) |
DefaultDuration governs how long an interactively answered prompt lives; on
a headless or unattended host, set DefaultAction = "allow" while you build the
ruleset and flip it to deny only once the log is quiet.
Trap 9 — ProcMonitorMethod decides whether process.path is reliable¶
The default and only good choice here is ebpf
(nixos/modules/services/security/opensnitch.nix:91, and the module wires
Ebpf.ModulesPath to config.boot.kernelPackages.opensnitch-ebpf for you). The
proc method reads /proc/<pid>/exe after seeing the connection, which loses
short-lived processes — precisely the curl, git-remote-https and updater
one-shots that process.path rules exist for. A lost process gets attributed to
an unknown path and falls through to DefaultAction.
This module asserts ebpf by default (requireEbpf). If your kernel has no
matching opensnitch-ebpf build, prefer audit over proc, and expect a
noisier auditd.
Trap 10 — this module does not touch networking.firewall¶
opensnitchd installs its own nftables (or iptables) hooks at runtime, from
settings.Firewall. That is independent of NixOS's networking.firewall
backend, so there is no conflict with networking.nftables.enable and this
recipe never writes networking.firewall.extraCommands — which the nftables
backend hard-asserts must be empty. If you set settings.Firewall = "iptables",
you need an iptables build with nftables compatibility, which is what nixpkgs
ships by default.
For host-level filtering, this composes with, but does not replace,
firewall-by-country and
egress-filter; for "which user may egress at all", see
per-uid-egress-lockdown. This recipe is the
per-process layer and is the only one of the four that can tell two programs
run by the same user apart.
Usage¶
{
imports = [ ./opensnitch-store-path-rules ];
services.opensnitch.settings = {
DefaultAction = "deny";
DefaultDuration = "until restart";
ProcMonitorMethod = "ebpf";
Firewall = "nftables";
};
services.opensnitchStorePathRules = {
enable = true;
binaries = {
# bin/curl, unwrapped
curl = { };
# different pname and leaf, and it is wrapped
tailscaled = {
pname = "tailscale";
subpath = "bin/tailscaled";
wrapped = true;
};
# executable buried under a second versioned directory: needs -.*
thunderbird = {
versionPattern = "-.*";
subpath = "lib/thunderbird/thunderbird";
};
# interpreter, with a version pattern of its own — see Trap 5
python3 = {
versionPattern = "-[0-9.]*";
pathRegex = "bin/python3(\\.[0-9]+)?";
};
};
rules = {
curl-web = {
binary = "curl";
ports = [ 443 80 ];
};
tailscaled.binary = "tailscaled";
thunderbird-mail = {
binary = "thunderbird";
ports = [ 465 587 993 ];
};
# interpreter rule, scoped as tightly as the destination allows
nas-sync = {
binary = "python3";
ports = [ 5001 ];
hostRegex = "^nas\\.example\\.internal$";
};
block-telemetry = {
action = "deny";
hostRegex = "^([a-z0-9-]+\\.)*telemetry\\.example\\.com$";
};
};
};
}
Rules with more than one operand are emitted as a list operator with the
operands in a fixed order — process, network, port, host — so the JSON is a pure
function of the spec and not of the order you wrote the options in.
Presets¶
The DSL is plain data, so profiles are //:
let
base = {
tailscaled.binary = "tailscaled";
curl-web = { binary = "curl"; ports = [ 443 80 ]; };
};
strict = base // {
curl-web = { binary = "curl"; ports = [ 443 ]; }; # no plaintext
};
in
{
services.opensnitchStorePathRules.rules =
if config.myHost.paranoid then strict else base;
}
Options¶
| Option | Default | Effect |
|---|---|---|
services.opensnitchStorePathRules.enable |
false |
Nothing is emitted until this is on. Also sets services.opensnitch.enable = mkDefault true. |
.storeHashPattern |
"[0-9a-z]+" |
Regex for the store hash. Never widen to .* (Trap 3). |
.versionPattern |
"-[^/]*" |
Default fragment between pname and the first /. -.* crosses directories. |
.sensitive |
false |
sensitive field on every generated operand. |
.created |
"1970-01-01T00:00:00Z" |
Constant created stamp (Trap 6). |
.binaries.<name>.pname |
attr name | Derivation name, matched literally. |
.binaries.<name>.versionPattern |
null |
Per-binary override of the module-wide pattern. |
.binaries.<name>.subpath |
bin/<name> |
Path inside the output, matched literally. |
.binaries.<name>.wrapped |
false |
Also match .NAME-wrapped (Trap 2). |
.binaries.<name>.pathRegex |
null |
Raw regex for everything after the store path. |
.binaries.<name>.regex |
null |
Raw regex for the whole process.path. Still linted. |
.rules.<name>.enable |
true |
false removes the rule file entirely. |
.rules.<name>.enabled |
true |
Ships the rule but marks it disabled to the daemon. |
.rules.<name>.action |
"allow" |
allow / deny / reject. |
.rules.<name>.duration |
"always" |
Anything else expires at runtime. |
.rules.<name>.precedence |
null |
null omits the field; true evaluates the rule first. |
.rules.<name>.binary |
null |
Key into binaries. |
.rules.<name>.processRegex |
null |
Raw process.path regex instead of binary. |
.rules.<name>.processPath |
null |
Exact process.path (Trap 1's strong-but-expiring form). |
.rules.<name>.network |
null |
dest.network CIDR. |
.rules.<name>.ports |
[ ] |
One port ⇒ simple; several ⇒ one anchored alternation. |
.rules.<name>.host / .hostRegex |
null |
dest.host, exact or regex (Trap 4). |
.rules.<name>.extraOperands |
[ ] |
Raw operands (user.id, process.command, iface.out, …). |
.extraRules |
{ } |
Raw rules merged verbatim into services.opensnitch.rules. |
.requireEbpf |
true |
Assert ProcMonitorMethod == "ebpf" (Trap 9). |
.lint.anchors |
true |
Assert ^…$ on every process regex. |
.lint.absolutePaths |
true |
Assert every process regex is rooted at /. |
.lint.emptyRules |
true |
Assert no rule compiles to zero operands. |
lint.emptyRules is worth a sentence of its own: a rule with no operands is not
a no-op, it is a match-everything rule. myrule = { }; with the default
action = "allow" disables the firewall, and does it in a way that reviews
cleanly. The assertion turns that into an evaluation failure.
Tests¶
test.nix is a NixOS VM test of the module itself. Run it standalone:
or from a flake: pkgs.callPackage ./modules/opensnitch-store-path-rules/test.nix { }.
Three nodes. origin serves HTTP and echoes back the client's source address.
shaped uses this module. pinned is the naive alternative — one hand-written
rule holding a literal store path — kept in the test as a live control.
Addresses come from lib/nixos-test-topology.
The subject is a set of probe binaries built at store paths whose shape the test
controls: netprobe-1.0, the same pname and version rebuilt to a different
hash (a dependency bump), netprobe-2.0, and
netprobe-2.1-unstable-2026-07-28. They are real working curls, so a rule
decision is observable as an HTTP request that either completes or does not.
What it proves:
- Eval time. Every lint is live: an unanchored
process.pathregex, a rule that compiles to zero operands, a reference to an undefined binary andProcMonitorMethod = "proc"each produce a failing assertion; a well-formed config produces none. Checked by readingconfig.assertionsfrom a plain non-VM evaluation, because an assertion failure aborts before there is a VM. - The generated rule matches the CURRENT store path — of all four builds,
against the paths
realpathresolves inside the guest, read from the rule file opensnitchd actually loaded from/var/lib/opensnitch/rules. - It pins nothing. No build's store hash appears anywhere in the rule file.
- It is not merely broad. Five near-miss paths must not match: a foreign
pname, a pname the target is a prefix of (
netprobelike-1.0), a longer leaf (bin/netprobe-helper), the wrong subdirectory (libexec/), and a.netprobe-wrappedpayload under a binary not declaredwrapped. wrapped = truematches both the wrapper and its.NAME-wrappedpayload.- Behaviourally, end to end, with opensnitchd running and
DefaultAction = "deny": all four builds plus both wrapped forms fetch the page; all five near-misses time out. Same node, same URL, byte-identical binaries, seconds apart — the only difference is the store path, which is what rules out "the network was down" as the reason a denied probe failed. - The point. On
pinned, the literal rule allows the exact path it names and blocks all three rebuilds. Onshaped, the same three rebuilds are allowed. If this module ever regressed into pinning a path, the two nodes would agree and the test would fail.
Not covered: the GUI/database side (Trap 7) and precedence ordering between declarative and runtime rules.
Verified by breaking it¶
A test nobody has watched fail is a claim. Each of these was applied to a copy of the recipe and the test was re-run:
| break | result |
|---|---|
Delete the emptyRules assertion |
eval fails: a rule that compiles to zero operands is rejected |
Emit bin/netprobe.*$ instead of bin/netprobe$ — still anchored, still passes every lint |
static lane fails: decoy leaf-suffix matched: …/bin/netprobe-helper |
| Same break, static lane blinded | behavioural lane fails on its own: decoy leaf-suffix was allowed; the rule is too broad — and the decoy genuinely reached origin (rc=0, source address echoed), so the deny in the passing run is a real deny |
Regress the rule to a literal store path in regexp form (passes every lint and every static type check), static lane blinded |
behavioural lane fails: v1-rebuilt was blocked but should have been allowed, with v1 succeeding moments earlier |
The first break run also exposed a race in the test itself: opensnitchd reports
active about half a second before it logs [eBPF] module loaded, so the
monitor-method check is a wait_until_succeeds, not a succeed.
Note that nixos-test-topology's Trap 4 —
client and destination must be on different subnets — does not apply here.
OpenSnitch filters the client's own outbound connections on the client itself;
there is no forward hook to ARP around. The equivalent "did the filter actually
run" instrument is the paired allow/deny control in (6).
Caveats¶
- Only
process.pathregexes are linted.hostRegexand anything inextraOperandsare passed through untouched — deliberately, because a destination pattern is usually supposed to be broad (Trap 4). operator.listcarries nodatafield here. The daemon's own UI writes a JSON-encoded copy of the operand list intodatawhen it saves a rule; opensnitchd accepts both forms on load, but a rule round-tripped through the GUI will not be byte-identical to the generated one. UseextraRulesif you need to reproduce a hand-written rule exactly.- Container and VM processes are invisible-ish. A process in a container reports the path inside its mount namespace, which is not a host store path; a VM's guest traffic appears as the hypervisor binary. Match the hypervisor or the container runtime and constrain the destination — you cannot distinguish guests this way.
- Multi-process applications need multiple rules. Browsers, Electron apps and anything with helper processes may connect from a helper whose path differs from the launcher. Read the daemon log rather than guessing.
- Rule files are named after the attribute key and loaded from
settings.Rules.Path. Ordering between rules of equal precedence is not something to depend on; useprecedence = truefor a deny that must win. - Version bounds. Written against nixpkgs
26.11(opensnitch1.8.0). The rule schema used here —type = "list"operators with nested operands,regexpoperands onprocess.path/dest.host/dest.port— has been stable since 1.4.Ebpf.ModulesPathand theRules.Path-must-be-under-/var/libassertion are nixpkgs-module behaviour, not upstream's. - This is not a sandbox. A process that can
execvea different binary, or that is already allowed to reach a host that proxies onward, is not contained by a path rule. It is an observability and mistake control: it tells you what talks to what, and stops the things you did not intend. Pair it with a real isolation mechanism where that matters.
Source¶
modules/opensnitch-store-path-rules/default.nix
{
config,
lib,
...
}:
let
inherit (lib)
concatMapStrings
concatStringsSep
filterAttrs
mapAttrs
mapAttrsToList
mkEnableOption
mkIf
mkOption
optional
optionalAttrs
types
;
cfg = config.services.opensnitchStorePathRules;
# RE2 (Go's regexp, what opensnitchd compiles rule regexes with) has no
# \Q..\E, so literal path components have to be escaped by hand. Backslash
# first, or every escape we add gets escaped again.
escapeRe =
s:
builtins.replaceStrings
[
"\\"
"."
"+"
"*"
"?"
"("
")"
"["
"]"
"{"
"}"
"|"
"^"
"$"
]
[
"\\\\"
"\\."
"\\+"
"\\*"
"\\?"
"\\("
"\\)"
"\\["
"\\]"
"\\{"
"\\}"
"\\|"
"\\^"
"\\$"
]
s;
# "bin/foo" -> { dir = "bin/"; leaf = "foo"; }, both already regex-escaped.
splitSubpath =
subpath:
let
parts = lib.splitString "/" subpath;
in
{
dir = concatMapStrings (p: escapeRe p + "/") (lib.init parts);
leaf = lib.last parts;
};
# The whole point of the recipe: a matcher for a *shape* of store path, not
# for one hash. See README "Trap 1".
binaryRegex =
b:
if b.regex != null then
b.regex
else
let
version = if b.versionPattern != null then b.versionPattern else cfg.versionPattern;
prefix = "^/nix/store/${cfg.storeHashPattern}-${escapeRe b.pname}${version}/";
parts = splitSubpath b.subpath;
leaf =
if b.wrapped then "(\\.)?${escapeRe parts.leaf}(-wrapped)?" else escapeRe parts.leaf;
tail = if b.pathRegex != null then b.pathRegex else parts.dir + leaf;
in
prefix + tail + "$";
binaryRegexes = mapAttrs (_: binaryRegex) cfg.binaries;
operand = type: name: data: {
inherit type;
operand = name;
sensitive = cfg.sensitive;
inherit data;
};
portOperand =
ports:
if builtins.length ports == 1 then
operand "simple" "dest.port" (toString (builtins.head ports))
else
operand "regexp" "dest.port" "^(${concatStringsSep "|" (map toString ports)})$";
processOperand =
r:
if r.processPath != null then
operand "simple" "process.path" r.processPath
else if r.processRegex != null then
operand "regexp" "process.path" r.processRegex
else
operand "regexp" "process.path" binaryRegexes.${r.binary};
# Operand order is fixed — process, network, port, host — so that a rule's
# generated JSON is a pure function of its spec and never depends on the
# order somebody happened to write the options in.
operandsOf =
r:
optional (r.binary != null || r.processRegex != null || r.processPath != null) (processOperand r)
++ optional (r.network != null) (operand "network" "dest.network" r.network)
++ optional (r.ports != [ ]) (portOperand r.ports)
++ optional (r.host != null) (operand "simple" "dest.host" r.host)
++ optional (r.hostRegex != null) (operand "regexp" "dest.host" r.hostRegex)
++ r.extraOperands;
ruleJson =
name: r:
let
ops = operandsOf r;
in
{
name = if r.ruleName != null then r.ruleName else name;
created = if r.created != null then r.created else cfg.created;
inherit (r) enabled action duration;
operator =
if builtins.length ops == 1 then
builtins.head ops
else
{
type = "list";
operand = "list";
list = ops;
};
}
// optionalAttrs (r.precedence != null) { inherit (r) precedence; };
generated = mapAttrs ruleJson (filterAttrs (_: r: r.enable) cfg.rules);
binaryType = types.submodule (
{ name, ... }:
{
options = {
pname = mkOption {
description = ''
Derivation name as it appears in the store path, i.e. the text
between the hash and the version. Matched literally (regex-escaped).
'';
type = types.str;
default = name;
defaultText = lib.literalMD "the attribute name";
};
versionPattern = mkOption {
description = ''
Regex fragment matching everything between `pname` and the first
path separator. `null` uses {option}`services.opensnitchStorePathRules.versionPattern`.
Must start with a separator (`-`) or be optional, otherwise
`foo` also matches `foobar`.
'';
type = types.nullOr types.str;
default = null;
example = "-[0-9.]*";
};
subpath = mkOption {
description = ''
Path of the executable *inside* the store path, matched literally
(regex-escaped). Ignored when `pathRegex` or `regex` is set.
'';
type = types.str;
default = "bin/${name}";
defaultText = lib.literalMD "`bin/` + the attribute name";
example = "libexec/chromium/chromium";
};
wrapped = mkOption {
description = ''
Also match the `makeWrapper` payload: `.NAME-wrapped` next to
`NAME`. Set this for anything nixpkgs wraps — the wrapper `exec`s
the payload, so the path opensnitchd sees is the *payload's*.
See README "Trap 2".
'';
type = types.bool;
default = false;
};
pathRegex = mkOption {
description = ''
Raw regex (NOT escaped) for everything after the store path's
first `/`. Overrides `subpath` and `wrapped`.
'';
type = types.nullOr types.str;
default = null;
example = "bin/python3(\\.[0-9]+)?";
};
regex = mkOption {
description = ''
Raw regex (NOT escaped) for the whole `process.path`. Overrides
every other option here. Still linted.
'';
type = types.nullOr types.str;
default = null;
};
};
}
);
ruleType = types.submodule (
{ ... }:
{
options = {
enable = mkOption {
description = ''
Generate this rule at all. `false` removes the rule file, which is
NOT the same as `enabled = false` (that ships a disabled rule).
'';
type = types.bool;
default = true;
};
ruleName = mkOption {
description = "Rule `name` field. Defaults to the attribute name.";
type = types.nullOr types.str;
default = null;
};
enabled = mkOption {
description = "Rule `enabled` field, as understood by opensnitchd.";
type = types.bool;
default = true;
};
action = mkOption {
description = "What opensnitchd does with a matching connection.";
type = types.enum [
"allow"
"deny"
"reject"
];
default = "allow";
};
duration = mkOption {
description = ''
Lifetime of the rule. Anything other than "always" makes a
declarative rule expire at runtime, which is almost never what a
Nix-generated rule wants.
'';
type = types.str;
default = "always";
};
precedence = mkOption {
description = ''
Evaluate this rule before non-precedence rules. `null` omits the
field entirely (opensnitchd then treats it as false).
'';
type = types.nullOr types.bool;
default = null;
};
created = mkOption {
description = ''
Rule `created` timestamp. `null` uses the module-wide constant.
MUST be constant — see README "Trap 6".
'';
type = types.nullOr types.str;
default = null;
};
binary = mkOption {
description = ''
Key into {option}`services.opensnitchStorePathRules.binaries`,
turned into a `process.path` regexp operand.
'';
type = types.nullOr types.str;
default = null;
};
processRegex = mkOption {
description = "Raw `process.path` regexp, instead of `binary`.";
type = types.nullOr types.str;
default = null;
};
processPath = mkOption {
description = ''
Exact `process.path`. Only correct for paths that are stable across
rebuilds (`/usr/bin/...`, `/run/current-system/sw/bin/...` is NOT —
opensnitchd reports the resolved target, not the symlink).
'';
type = types.nullOr types.str;
default = null;
};
network = mkOption {
description = "`dest.network` CIDR operand.";
type = types.nullOr types.str;
default = null;
example = "192.168.0.0/16";
};
ports = mkOption {
description = ''
Destination ports. One port emits a `simple` operand, several emit
a single anchored alternation regexp. Empty list omits the operand
— see README "Trap 4" before adding one.
'';
type = types.listOf types.port;
default = [ ];
};
host = mkOption {
description = "Exact `dest.host`. See README \"Trap 4\".";
type = types.nullOr types.str;
default = null;
};
hostRegex = mkOption {
description = "`dest.host` regexp. Prefer this over `host`.";
type = types.nullOr types.str;
default = null;
};
extraOperands = mkOption {
description = ''
Raw operand attrsets appended after the generated ones, for
operands this DSL does not model (`dest.ip`, `user.id`,
`process.command`, `iface.out`, ...).
'';
type = types.listOf (types.attrsOf types.anything);
default = [ ];
};
};
}
);
# ---- lints -------------------------------------------------------------
processRegexes =
lib.mapAttrsToList (n: b: {
what = "binaries.${n}";
re = binaryRegex b;
}) cfg.binaries
++ lib.concatLists (
mapAttrsToList (
n: r: optional (r.processRegex != null) {
what = "rules.${n}.processRegex";
re = r.processRegex;
}
) cfg.rules
);
unanchored = builtins.filter (
p: !(lib.hasPrefix "^" p.re && lib.hasSuffix "$" p.re)
) processRegexes;
unrooted = builtins.filter (p: !(lib.hasPrefix "^/nix/store/" p.re || lib.hasPrefix "^/" p.re)) processRegexes;
danglingBinaryRefs = builtins.filter (n: !(cfg.binaries ? ${n})) (
lib.concatLists (mapAttrsToList (_: r: optional (r.binary != null) r.binary) cfg.rules)
);
emptyRules = mapAttrsToList (n: _: n) (
filterAttrs (_: r: r.enable && operandsOf r == [ ]) cfg.rules
);
in
{
options.services.opensnitchStorePathRules = {
enable = mkEnableOption "declarative OpenSnitch rules keyed on store-path *shapes*";
storeHashPattern = mkOption {
description = ''
Regex fragment matching the store hash. The default deliberately
excludes `-` and `/`, so the fragment cannot run past the first
component of the store path. Never use `.*` here.
'';
type = types.str;
default = "[0-9a-z]+";
};
versionPattern = mkOption {
description = ''
Default regex fragment between `pname` and the first `/`.
`-[^/]*` cannot cross a directory boundary. `-.*` can (RE2's `.`
matches `/`), which is needed for executables that live under a
second versioned directory — and is also how an over-broad rule
accidentally matches an unrelated binary. See README "Trap 3".
'';
type = types.str;
default = "-[^/]*";
example = "-.*";
};
sensitive = mkOption {
description = ''
Rule `sensitive` field (case-sensitive matching). Emitted on every
generated operand.
'';
type = types.bool;
default = false;
};
created = mkOption {
description = ''
`created` timestamp stamped into every generated rule. A constant on
purpose: anything derived from the current time makes the rule's store
path change on every evaluation. See README "Trap 6".
'';
type = types.str;
default = "1970-01-01T00:00:00Z";
};
binaries = mkOption {
description = ''
Named `process.path` matchers. Each one describes the *shape* of a
store path, so it keeps matching after the package is rebuilt.
'';
type = types.attrsOf binaryType;
default = { };
example = lib.literalExpression ''
{
curl = { }; # bin/curl
tailscaled = { pname = "tailscale"; subpath = "bin/tailscaled"; wrapped = true; };
chromium = { versionPattern = "(-unwrapped)?-[^/]*"; subpath = "libexec/chromium/chromium"; };
}
'';
};
rules = mkOption {
description = ''
Rules, in a small DSL that compiles to
{option}`services.opensnitch.rules`.
'';
type = types.attrsOf ruleType;
default = { };
example = lib.literalExpression ''
{
curl-web = { binary = "curl"; ports = [ 80 443 ]; };
block-telemetry = { action = "deny"; hostRegex = "^telemetry\\..*$"; };
}
'';
};
extraRules = mkOption {
description = ''
Raw rules merged into {option}`services.opensnitch.rules` verbatim,
for anything the DSL does not express (and for reproducing a
hand-written rule byte-for-byte).
'';
type = types.attrsOf (types.attrsOf types.anything);
default = { };
};
requireEbpf = mkOption {
description = ''
Assert `settings.ProcMonitorMethod == "ebpf"`. The `proc` monitor
reads `/proc/<pid>/exe` after the fact and loses short-lived
processes, so `process.path` rules silently stop matching for exactly
the programs (updaters, `curl` one-shots) they were written for.
'';
type = types.bool;
default = true;
};
lint = {
anchors = mkOption {
description = ''
Assert every `process.path` regex is anchored with `^` and `$`.
Unanchored is a substring match in RE2 — `bin/nc` would also match
`.../bin/ncat`.
'';
type = types.bool;
default = true;
};
absolutePaths = mkOption {
description = ''
Assert every `process.path` regex is rooted at an absolute path.
'';
type = types.bool;
default = true;
};
emptyRules = mkOption {
description = ''
Assert no rule compiles to zero operands. A rule with no operands
matches *everything*, so an `allow` typo silently disables the
firewall.
'';
type = types.bool;
default = true;
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = danglingBinaryRefs == [ ];
message =
"services.opensnitchStorePathRules: rules reference undefined binaries: "
+ concatStringsSep ", " danglingBinaryRefs;
}
{
assertion = !cfg.lint.emptyRules || emptyRules == [ ];
message =
"services.opensnitchStorePathRules: these rules have no operands and would match every connection: "
+ concatStringsSep ", " emptyRules;
}
{
assertion = !cfg.lint.anchors || unanchored == [ ];
message =
"services.opensnitchStorePathRules: unanchored process.path regex (RE2 matches substrings) in: "
+ concatStringsSep ", " (map (p: p.what) unanchored);
}
{
assertion = !cfg.lint.absolutePaths || unrooted == [ ];
message =
"services.opensnitchStorePathRules: process.path regex is not rooted at an absolute path in: "
+ concatStringsSep ", " (map (p: p.what) unrooted);
}
{
assertion =
!cfg.requireEbpf || (config.services.opensnitch.settings.ProcMonitorMethod or "ebpf") == "ebpf";
message = ''
services.opensnitchStorePathRules: process.path rules need
services.opensnitch.settings.ProcMonitorMethod = "ebpf"; the "proc"
and "ftrace" monitors miss short-lived processes. Set requireEbpf =
false if you accept that.
'';
}
];
services.opensnitch = {
enable = lib.mkDefault true;
rules = generated // cfg.extraRules;
};
};
}